1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18const otherJobSecret = "ermitejob_ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkj"
19
20func warn(step int32, msg string) domain.CIAnnotation {
21 return domain.CIAnnotation{Level: domain.AnnotationWarning, Step: step, Message: msg}
22}
23
24// UpdateJob replaces the annotations of the steps it names, in the batch of the
25// job, also for a completed step and a step that is not in the call; a retry
26// writes nothing.
27func TestUpdateJobReplacesAnnotationsPerStep(t *testing.T) {
28 t.Parallel()
29 u := newCIUnit(t, nil)
30 j := ciJob(t, domain.CIPhaseRunning, jobSecret)
31 j.Steps = []domain.CIStep{{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}}
32 u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(jobSecret), mock.Anything).Return(j, nil)
33 u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
34 u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(1, "old"), warn(5, "kept")}, nil).Once()
35 var written ports.CIWrite
36 u.store.EXPECT().Apply(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, w ports.CIWrite) (uint64, error) {
37 written = w
38 return 9, nil
39 }).Once()
40 u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo}, nil)
41 u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
42 u.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
43
44 // Step 1 is completed: its annotations are replaced, the step is not touched.
45 completed := ports.StepUpdate{Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: now, CompletedAt: now}
46 require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{completed},
47 []domain.CIAnnotation{warn(1, "new"), warn(1, "new too"), warn(9, "a step not in the call")}))
48
49 require.Len(t, written.Annotations, 2, "step 5 is not named: it is kept")
50 assert.EqualValues(t, 1, written.Annotations[0].Step)
51 assert.Equal(t, []string{"new", "new too"}, []string{written.Annotations[0].Items[0].Message, written.Annotations[0].Items[1].Message})
52 assert.EqualValues(t, 9, written.Annotations[1].Step)
53 assert.Equal(t, ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}, written.Annotations[0].Exec, "keyed by the attempt the job executes in")
54 assert.Equal(t, j.Repo, written.Annotations[0].Repo)
55 require.Len(t, written.Jobs, 1, "annotations travel with the job record")
56}
57
58func TestUpdateJobRetryWritesNothing(t *testing.T) {
59 t.Parallel()
60 u := newCIUnit(t, nil)
61 j := ciJob(t, domain.CIPhaseRunning, jobSecret)
62 u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
63 u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
64 u.store.EXPECT().JobAnnotations(mock.Anything, j.RunID, j.Attempt, j.ID).Return([]domain.CIAnnotation{warn(2, "same")}, nil)
65 // No Apply is expected: the mock fails the test on one.
66 require.NoError(t, u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(2, "same")}))
67}
68
69// A call that breaks a limit is refused before anything is read or written.
70func TestUpdateJobAnnotationLimitsStopBeforeTheStore(t *testing.T) {
71 t.Parallel()
72 u := newCIUnit(t, nil)
73 j := ciJob(t, domain.CIPhaseRunning, jobSecret)
74 u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
75 eleven := make([]domain.CIAnnotation, 11)
76 for i := range eleven {
77 eleven[i] = warn(3, "m")
78 }
79 for name, anns := range map[string][]domain.CIAnnotation{
80 "eleven": eleven,
81 "too long": {warn(1, strings.Repeat("x", 4097))},
82 "no message": {warn(1, "")},
83 } {
84 err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, nil, anns)
85 assert.ErrorIs(t, err, domain.ErrInvalid, name)
86 }
87}
88
89func TestResultsAreRefusedWithAnotherJobsToken(t *testing.T) {
90 t.Parallel()
91 u := newCIUnit(t, nil)
92 other := ciJob(t, domain.CIPhaseRunning, otherJobSecret)
93 other.ID = "7-1-0"
94 u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(otherJobSecret), mock.Anything).Return(other, nil)
95 ctx := context.Background()
96
97 err := u.ci.UpdateJob(ctx, otherJobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")})
98 assert.ErrorIs(t, err, domain.ErrForbidden)
99 err = u.ci.UploadSummary(ctx, otherJobSecret, jobIDOne, 1, "x")
100 assert.ErrorIs(t, err, domain.ErrForbidden)
101 _, err = u.ci.CompleteJob(ctx, otherJobSecret, ports.CompleteJobParams{JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "x")}})
102 assert.ErrorIs(t, err, domain.ErrForbidden)
103
104 // Not a job token at all.
105 for _, secret := range []string{"", "ermiterun_" + strings.Repeat("a", 43), "ermite_" + strings.Repeat("a", 43)} {
106 assert.ErrorIs(t, u.ci.UploadSummary(ctx, secret, jobIDOne, 1, "x"), domain.ErrUnauthenticated, secret)
107 }
108}
109
110func TestEndedJobsRejectAnnotationsAndSummaries(t *testing.T) {
111 t.Parallel()
112 u := newCIUnit(t, nil)
113 j := ciJob(t, domain.CIPhaseEnded, jobSecret)
114 j.Conclusion, j.Error = domain.CICancelled, "stopped"
115 u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
116 ctx := context.Background()
117
118 for name, err := range map[string]error{
119 "UpdateJob": u.ci.UpdateJob(ctx, jobSecret, jobIDOne, nil, []domain.CIAnnotation{warn(1, "x")}),
120 "UploadSummary": u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, "x"),
121 } {
122 ended, ok := domain.IsJobEnded(err)
123 require.True(t, ok, name)
124 assert.Equal(t, domain.CICancelled, ended.Conclusion, name)
125 assert.Equal(t, "stopped", ended.Message, name)
126 }
127
128 // CompleteJob on an ended job returns the stored result and stores nothing,
129 // whatever the annotations: the mocks expect no read and no write.
130 res, err := u.ci.CompleteJob(ctx, jobSecret, ports.CompleteJobParams{
131 JobID: jobIDOne, Conclusion: domain.CISuccess, Annotations: []domain.CIAnnotation{warn(1, "ignored")},
132 })
133 require.NoError(t, err)
134 assert.False(t, res.Applied)
135 assert.Equal(t, domain.CICancelled, res.Conclusion)
136}
137
138func TestUploadSummaryRules(t *testing.T) {
139 t.Parallel()
140 u := newCIUnit(t, nil)
141 j := ciJob(t, domain.CIPhaseRunning, jobSecret)
142 u.store.EXPECT().FindJobByToken(mock.Anything, mock.Anything, mock.Anything).Return(j, nil)
143 u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
144 ctx := context.Background()
145 ref := ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
146
147 for name, tc := range map[string]struct {
148 step int32
149 md string
150 }{
151 "step 0": {0, "x"},
152 "step 1001": {1001, "x"},
153 "1 MiB and a byte": {1, strings.Repeat("x", 1<<20+1)},
154 "invalid UTF-8": {1, "a\xffb"},
155 "negative": {-1, "x"},
156 } {
157 assert.ErrorIs(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, tc.step, tc.md), domain.ErrInvalid, name)
158 }
159
160 max := strings.Repeat("x", 1<<20)
161 u.sums.EXPECT().Put(mock.Anything, ref, int32(1), max).Return(nil).Once()
162 u.sums.EXPECT().Put(mock.Anything, ref, int32(1000), "").Return(nil).Once()
163 require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1, max), "exactly 1 MiB")
164 require.NoError(t, u.ci.UploadSummary(ctx, jobSecret, jobIDOne, 1000, ""), "empty removes")
165}
166
167// The summaries are read through the same access as GetJob, per attempt of the
168// job, at most 20.
169func TestGetStepSummariesAndListAnnotations(t *testing.T) {
170 t.Parallel()
171 u := newCIUnit(t, nil)
172 repo := name(t, "proj")
173 j := ciJob(t, domain.CIPhaseEnded, jobSecret)
174 j.Attempt, j.RunAttempt = 1, 2 // kept from attempt 1 by a re-run
175 u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
176 u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 2}, nil)
177 u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(2), jobIDOne).Return(j, nil)
178 u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(2)).Return([]domain.CIJob{j}, nil)
179 u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return([]domain.CIAnnotation{warn(1, "from attempt 1")}, nil)
180 u.sums.EXPECT().List(mock.Anything, ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}, domain.CIMaxListedSummaries, int64(0)).
181 Return([]domain.CIStepSummary{{Step: 3, Markdown: "s"}}, true, nil)
182 ctx := context.Background()
183
184 anns, err := u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "")
185 require.NoError(t, err)
186 require.Len(t, anns.Annotations, 1)
187 assert.Equal(t, jobIDOne, anns.Annotations[0].JobID)
188 assert.False(t, anns.Truncated)
189
190 sums, err := u.ci.GetStepSummaries(ctx, domain.Anonymous(), repo, jobIDOne, 0, 0)
191 require.NoError(t, err)
192 assert.True(t, sums.Truncated)
193 assert.Equal(t, []domain.CIStepSummary{{Step: 3, Markdown: "s"}}, sums.Summaries)
194
195 _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 3, "")
196 assert.ErrorIs(t, err, domain.ErrNotFound, "an attempt that does not exist")
197 _, err = u.ci.ListAnnotations(ctx, domain.Anonymous(), repo, 7, 0, "7-9-0")
198 assert.ErrorIs(t, err, domain.ErrNotFound, "a job that is not of the run")
199}
200
201func TestListAnnotationsIsBoundedAndTruncated(t *testing.T) {
202 t.Parallel()
203 u := newCIUnit(t, nil)
204 repo := name(t, "proj")
205 j := ciJob(t, domain.CIPhaseEnded, jobSecret)
206 u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
207 u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil)
208 u.store.EXPECT().ListJobs(mock.Anything, int64(7), int32(1)).Return([]domain.CIJob{j}, nil)
209 many := make([]domain.CIAnnotation, 1001)
210 for i := range many {
211 many[i] = warn(int32(i%1000)+1, "m")
212 }
213 u.store.EXPECT().JobAnnotations(mock.Anything, int64(7), int32(1), jobIDOne).Return(many, nil)
214 got, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
215 require.NoError(t, err)
216 assert.Len(t, got.Annotations, 1000)
217 assert.True(t, got.Truncated)
218}
219
220// A private repository is not readable by an anonymous caller: not found, as
221// for GetRun.
222func TestResultsReadsFollowTheAccessOfTheRepository(t *testing.T) {
223 t.Parallel()
224 u := newCIUnit(t, nil)
225 repo := name(t, "proj")
226 u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}, nil)
227 _, err := u.ci.ListAnnotations(context.Background(), domain.Anonymous(), repo, 7, 0, "")
228 assert.ErrorIs(t, err, domain.ErrNotFound)
229 _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 0)
230 assert.ErrorIs(t, err, domain.ErrNotFound)
231}
232
233// The byte budget of a reader reaches the store, and a negative one means none.
234func TestGetStepSummariesPassesTheBudget(t *testing.T) {
235 t.Parallel()
236 u := newCIUnit(t, nil)
237 repo := name(t, "proj")
238 j := ciJob(t, domain.CIPhaseEnded, jobSecret)
239 u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
240 u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Repo: repo, ID: 7, Attempt: 1}, nil)
241 u.store.EXPECT().GetJob(mock.Anything, int64(7), int32(1), jobIDOne).Return(j, nil)
242 ref := ports.CILogRef{RunID: 7, JobID: jobIDOne, Attempt: 1}
243 u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(4096)).
244 Return([]domain.CIStepSummary{{Step: 1, Markdown: "a"}, {Step: 2, Omitted: true}}, false, nil).Once()
245 u.sums.EXPECT().List(mock.Anything, ref, domain.CIMaxListedSummaries, int64(0)).Return(nil, false, nil).Once()
246
247 got, err := u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, 4096)
248 require.NoError(t, err)
249 assert.True(t, got.Summaries[1].Omitted)
250 _, err = u.ci.GetStepSummaries(context.Background(), domain.Anonymous(), repo, jobIDOne, 0, -5)
251 require.NoError(t, err)
252}