v0.15.0 8a6cfec

ci_results_unit_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close