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-api/gen/ermite/ermiteconnect"
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22// RunService.GetJob: the job with its steps and the range of their lines, the
23// states and conclusions of the contract, and the codes of the refusals.
24func TestRunServiceGetJob(t *testing.T) {
25 t.Parallel()
26 at := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC)
27 job := ports.JobView{CIJob: domain.CIJob{
28 ID: "7-0-0", RunID: 7, Attempt: 2, RunAttempt: 2, Repo: mustRepoName(t, "demo"), Key: "build", Name: "build (linux)",
29 Phase: domain.CIPhaseEnded, Conclusion: domain.CIFailure, Error: "boom", Labels: []string{"self-hosted"}, Needs: []string{"lint"},
30 RunnerName: "alpha", Isolation: domain.CIIsolationVM, CreatedAt: at, StartedAt: at, CompletedAt: at.Add(time.Minute), Version: 9,
31 Steps: []domain.CIStep{
32 {Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 0},
33 {Number: 2, Name: "Run make", State: domain.CIStepCompleted, Conclusion: domain.CIFailure, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 3},
34 },
35 }, Lines: 8}
36
37 runs := mocks.NewRunService(t)
38 runs.EXPECT().GetJob(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), "7-0-0", int32(2)).Return(job, nil).Once()
39 resp, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: "demo", JobId: "7-0-0", Attempt: 2}))
40 require.NoError(t, err)
41 got := resp.Msg.GetJob()
42 assert.Equal(t, "7-0-0", got.GetId())
43 assert.EqualValues(t, 2, got.GetAttempt())
44 assert.Equal(t, "build (linux)", got.GetName())
45 assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, got.GetStatus())
46 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, got.GetConclusion())
47 assert.Equal(t, "boom", got.GetError())
48 assert.Equal(t, pb.RunnerIsolation_RUNNER_ISOLATION_VM, got.GetIsolation())
49 assert.Equal(t, []string{"lint"}, got.GetNeeds())
50 assert.EqualValues(t, 9, got.GetVersion())
51 require.Len(t, got.GetSteps(), 2)
52 assert.Equal(t, []int64{0, 3}, []int64{got.GetSteps()[0].GetLogStart(), got.GetSteps()[0].GetLogEnd()}, "a step ends where the next begins")
53 assert.Equal(t, []int64{3, 8}, []int64{got.GetSteps()[1].GetLogStart(), got.GetSteps()[1].GetLogEnd()}, "the last ends at the end of the log")
54 assert.Equal(t, pb.StepState_STEP_STATE_COMPLETED, got.GetSteps()[1].GetState())
55
56 for name, tc := range map[string]struct {
57 repo string
58 err error
59 code connect.Code
60 }{
61 "invalid repo": {"../x", nil, connect.CodeInvalidArgument},
62 "unknown job": {"demo", domain.ErrNotFound, connect.CodeNotFound},
63 "not readable": {"demo", domain.ErrNotFound, connect.CodeNotFound},
64 "store failure": {"demo", assert.AnError, connect.CodeInternal},
65 } {
66 t.Run(name, func(t *testing.T) {
67 t.Parallel()
68 runs := mocks.NewRunService(t)
69 if tc.err != nil {
70 runs.EXPECT().GetJob(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.JobView{}, tc.err).Once()
71 }
72 _, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: tc.repo, JobId: "7-0-0"}))
73 requireCode(t, tc.code, err)
74 })
75 }
76}
77
78// The end of a job reaches the runner as failed_precondition with the JobEnded
79// detail; a bad credential counts against the client address.
80func TestRunnerServiceErrors(t *testing.T) {
81 t.Parallel()
82 ended := &domain.JobEndedError{Conclusion: domain.CICancelled, Message: "stopped"}
83 err := ciError(ownerCtx(), ended)
84 requireCode(t, connect.CodeFailedPrecondition, err)
85 var ce *connect.Error
86 require.ErrorAs(t, err, &ce)
87 require.Len(t, ce.Details(), 1)
88 msg, derr := ce.Details()[0].Value()
89 require.NoError(t, derr)
90 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, msg.(*pb.JobEnded).GetConclusion())
91 assert.Equal(t, "stopped", msg.(*pb.JobEnded).GetError())
92
93 requireCode(t, connect.CodeOutOfRange, ciError(ownerCtx(), &ports.ErrCILogGap{Count: 4}))
94
95 limiter := mocks.NewFailureLimiter(t)
96 limiter.EXPECT().Fail(mock.Anything, "client-key").Return().Once()
97 s := &runnerService{runners: mocks.NewRunnerService(t), limiter: limiter}
98 ctx := t.Context()
99 ctx = context.WithValue(ctx, addrKey{}, "client-key")
100 requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
101 requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
102}
103
104// The annotations of a request are mapped to the domain; a level that is not
105// one of the three is invalid_argument before the use case sees the call.
106func TestRunnerServiceAnnotationsAndSummary(t *testing.T) {
107 t.Parallel()
108 anns := []*pb.Annotation{
109 {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, StepNumber: 5},
110 {Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "n", StepNumber: 1},
111 }
112 want := []domain.CIAnnotation{
113 {Level: domain.AnnotationFailure, Title: "t", Message: "m", File: "f.go", StartLine: 1, EndLine: 2, StartColumn: 3, EndColumn: 4, Step: 5},
114 {Level: domain.AnnotationNotice, Message: "n", Step: 1},
115 }
116 ctx := context.WithValue(ownerCtx(), secretKey{}, "ermitejob_x")
117
118 runners := mocks.NewRunnerService(t)
119 runners.EXPECT().UpdateJob(mock.Anything, "ermitejob_x", "7-0-0", mock.Anything, want).Return(nil).Once()
120 runners.EXPECT().CompleteJob(mock.Anything, "ermitejob_x", mock.MatchedBy(func(p ports.CompleteJobParams) bool {
121 return p.JobID == "7-0-0" && len(p.Annotations) == 2 && p.Annotations[0] == want[0]
122 })).Return(ports.CompleteJobResult{Conclusion: domain.CISuccess, Applied: true}, nil).Once()
123 runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "# md").Return(nil).Once()
124 runners.EXPECT().UploadSummary(mock.Anything, "ermitejob_x", "7-0-0", int32(3), "").Return(&domain.JobEndedError{Conclusion: domain.CIFailure}).Once()
125 s := &runnerService{runners: runners}
126
127 _, err := s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: anns}))
128 require.NoError(t, err)
129 _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: anns}))
130 require.NoError(t, err)
131 _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3, Markdown: "# md"}))
132 require.NoError(t, err)
133 _, err = s.UploadSummary(ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: "7-0-0", StepNumber: 3}))
134 requireCode(t, connect.CodeFailedPrecondition, err)
135
136 _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Message: "no level", StepNumber: 1}}}))
137 requireCode(t, connect.CodeInvalidArgument, err)
138 _, err = s.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: "7-0-0", Annotations: []*pb.Annotation{{Level: 99, Message: "x", StepNumber: 1}}}))
139 requireCode(t, connect.CodeInvalidArgument, err)
140 _, err = s.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: "7-0-0", Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{{Message: "x"}}}))
141 requireCode(t, connect.CodeInvalidArgument, err)
142}
143
144// StepUpdate.outcome reaches the use case, and Step.outcome is the conclusion
145// for a completed step stored without one, and unset before the end.
146func TestStepOutcomeMapping(t *testing.T) {
147 t.Parallel()
148 ups, err := stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE}})
149 require.NoError(t, err)
150 assert.Equal(t, domain.CIFailure, ups[0].Outcome)
151 _, err = stepUpdatesFromPB([]*pb.StepUpdate{{Number: 1, Outcome: 99}})
152 assert.Error(t, err)
153
154 steps := stepsToPB([]domain.CIStep{
155 {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure},
156 {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CIFailure},
157 {Number: 3, State: domain.CIStepInProgress},
158 {Number: 4, State: domain.CIStepCompleted, Conclusion: domain.CISkipped, Outcome: domain.CISkipped},
159 }, 0)
160 got := make([]pb.CiConclusion, len(steps))
161 for i, s := range steps {
162 got[i] = s.GetOutcome()
163 }
164 assert.Equal(t, []pb.CiConclusion{
165 pb.CiConclusion_CI_CONCLUSION_FAILURE, pb.CiConclusion_CI_CONCLUSION_FAILURE,
166 pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED, pb.CiConclusion_CI_CONCLUSION_SKIPPED,
167 }, got)
168}
169
170func TestRunServiceAnnotationsAndSummaries(t *testing.T) {
171 t.Parallel()
172 repo := mustRepoName(t, "demo")
173 runs := mocks.NewRunService(t)
174 runs.EXPECT().ListAnnotations(mock.Anything, domain.Owner(), repo, int64(7), int32(2), "7-0-0").Return(ports.AnnotationList{
175 Annotations: []ports.JobAnnotation{{JobID: "7-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 4, Step: 3}}},
176 Truncated: true,
177 }, nil).Once()
178 runs.EXPECT().GetStepSummaries(mock.Anything, domain.Owner(), repo, "7-0-0", int32(0), int64(0)).Return(ports.SummaryList{
179 Summaries: []domain.CIStepSummary{{Step: 2, Markdown: "# s"}}, Truncated: true,
180 }, nil).Once()
181 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(8), int32(0), "").Return(ports.AnnotationList{}, domain.ErrNotFound).Once()
182 s := &runService{runs: runs}
183
184 res, err := s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 7, Attempt: 2, JobId: "7-0-0"}))
185 require.NoError(t, err)
186 require.Len(t, res.Msg.GetAnnotations(), 1)
187 a := res.Msg.GetAnnotations()[0]
188 assert.Equal(t, "7-0-0", a.GetJobId())
189 assert.Equal(t, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, a.GetAnnotation().GetLevel())
190 assert.EqualValues(t, 3, a.GetAnnotation().GetStepNumber())
191 assert.EqualValues(t, 4, a.GetAnnotation().GetStartLine())
192 assert.True(t, res.Msg.GetTruncated())
193
194 sums, err := s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "demo", JobId: "7-0-0"}))
195 require.NoError(t, err)
196 require.Len(t, sums.Msg.GetSummaries(), 1)
197 assert.EqualValues(t, 2, sums.Msg.GetSummaries()[0].GetStepNumber())
198 assert.Equal(t, "# s", sums.Msg.GetSummaries()[0].GetMarkdown())
199 assert.True(t, sums.Msg.GetTruncated())
200
201 _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "demo", RunId: 8}))
202 requireCode(t, connect.CodeNotFound, err)
203 _, err = s.ListAnnotations(ownerCtx(), connect.NewRequest(&pb.ListAnnotationsRequest{Repo: "../x", RunId: 8}))
204 requireCode(t, connect.CodeInvalidArgument, err)
205 _, err = s.GetStepSummaries(ownerCtx(), connect.NewRequest(&pb.GetStepSummariesRequest{Repo: "../x"}))
206 requireCode(t, connect.CodeInvalidArgument, err)
207}
208
209// UploadSummary is a job call: its bearer secret is the job token, not a stored
210// token, and a summary of exactly 1 MiB passes the size limit of the runner side.
211func TestUploadSummaryIsAJobCallWithRoomForOneMiB(t *testing.T) {
212 t.Parallel()
213 assert.True(t, runnerSecretProcedures[ermiteconnect.RunnerServiceUploadSummaryProcedure])
214 assert.Greater(t, RunnerMaxRequestBytes, 1<<20+len("7-0-0")+16, "1 MiB plus the envelope")
215 assert.Equal(t, MaxRequestBytes, 1<<20, "the other services keep the 1 MiB limit")
216 assert.Contains(t, Features, "ci.annotations")
217 assert.Contains(t, Features, "ci.summaries")
218}