1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "testing"
8 "time"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 pb "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
19)
20
21// RunService.GetJob: the job with its steps and the range of their lines, the
22// states and conclusions of the contract, and the codes of the refusals.
23func TestRunServiceGetJob(t *testing.T) {
24 t.Parallel()
25 at := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC)
26 job := ports.JobView{CIJob: domain.CIJob{
27 ID: "7-0-0", RunID: 7, Attempt: 2, RunAttempt: 2, Repo: mustRepoName(t, "demo"), Key: "build", Name: "build (linux)",
28 Phase: domain.CIPhaseEnded, Conclusion: domain.CIFailure, Error: "boom", Labels: []string{"self-hosted"}, Needs: []string{"lint"},
29 RunnerName: "alpha", Isolation: domain.CIIsolationVM, CreatedAt: at, StartedAt: at, CompletedAt: at.Add(time.Minute), Version: 9,
30 Steps: []domain.CIStep{
31 {Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 0},
32 {Number: 2, Name: "Run make", State: domain.CIStepCompleted, Conclusion: domain.CIFailure, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 3},
33 },
34 }, Lines: 8}
35
36 runs := mocks.NewRunService(t)
37 runs.EXPECT().GetJob(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), "7-0-0", int32(2)).Return(job, nil).Once()
38 resp, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: "demo", JobId: "7-0-0", Attempt: 2}))
39 require.NoError(t, err)
40 got := resp.Msg.GetJob()
41 assert.Equal(t, "7-0-0", got.GetId())
42 assert.EqualValues(t, 2, got.GetAttempt())
43 assert.Equal(t, "build (linux)", got.GetName())
44 assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, got.GetStatus())
45 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, got.GetConclusion())
46 assert.Equal(t, "boom", got.GetError())
47 assert.Equal(t, pb.RunnerIsolation_RUNNER_ISOLATION_VM, got.GetIsolation())
48 assert.Equal(t, []string{"lint"}, got.GetNeeds())
49 assert.EqualValues(t, 9, got.GetVersion())
50 require.Len(t, got.GetSteps(), 2)
51 assert.Equal(t, []int64{0, 3}, []int64{got.GetSteps()[0].GetLogStart(), got.GetSteps()[0].GetLogEnd()}, "a step ends where the next begins")
52 assert.Equal(t, []int64{3, 8}, []int64{got.GetSteps()[1].GetLogStart(), got.GetSteps()[1].GetLogEnd()}, "the last ends at the end of the log")
53 assert.Equal(t, pb.StepState_STEP_STATE_COMPLETED, got.GetSteps()[1].GetState())
54
55 for name, tc := range map[string]struct {
56 repo string
57 err error
58 code connect.Code
59 }{
60 "invalid repo": {"../x", nil, connect.CodeInvalidArgument},
61 "unknown job": {"demo", domain.ErrNotFound, connect.CodeNotFound},
62 "not readable": {"demo", domain.ErrNotFound, connect.CodeNotFound},
63 "store failure": {"demo", assert.AnError, connect.CodeInternal},
64 } {
65 t.Run(name, func(t *testing.T) {
66 t.Parallel()
67 runs := mocks.NewRunService(t)
68 if tc.err != nil {
69 runs.EXPECT().GetJob(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.JobView{}, tc.err).Once()
70 }
71 _, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: tc.repo, JobId: "7-0-0"}))
72 requireCode(t, tc.code, err)
73 })
74 }
75}
76
77// The end of a job reaches the runner as failed_precondition with the JobEnded
78// detail; a bad credential counts against the client address.
79func TestRunnerServiceErrors(t *testing.T) {
80 t.Parallel()
81 ended := &domain.JobEndedError{Conclusion: domain.CICancelled, Message: "stopped"}
82 err := ciError(ownerCtx(), ended)
83 requireCode(t, connect.CodeFailedPrecondition, err)
84 var ce *connect.Error
85 require.ErrorAs(t, err, &ce)
86 require.Len(t, ce.Details(), 1)
87 msg, derr := ce.Details()[0].Value()
88 require.NoError(t, derr)
89 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, msg.(*pb.JobEnded).GetConclusion())
90 assert.Equal(t, "stopped", msg.(*pb.JobEnded).GetError())
91
92 requireCode(t, connect.CodeOutOfRange, ciError(ownerCtx(), &ports.ErrCILogGap{Count: 4}))
93
94 limiter := mocks.NewFailureLimiter(t)
95 limiter.EXPECT().Fail(mock.Anything, "client-key").Return().Once()
96 s := &runnerService{runners: mocks.NewRunnerService(t), limiter: limiter}
97 ctx := t.Context()
98 ctx = context.WithValue(ctx, addrKey{}, "client-key")
99 requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
100 requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
101}