1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "strings"
7 "testing"
8 "time"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13 "google.golang.org/protobuf/types/known/fieldmaskpb"
14
15 pb "bnema.dev/ermite-api/gen/ermite"
16 "bnema.dev/ermite/internal/domain"
17)
18
19// CI-2 results (phase E2): annotations and step summaries, from the runner's
20// calls to the API, the pages and the CLI.
21
22const resultsWorkflow = `name: ci
23on:
24 push:
25 branches: [main]
26jobs:
27 build:
28 runs-on: ubuntu-latest
29 steps:
30 - run: echo hello
31 lint:
32 runs-on: ubuntu-latest
33 steps:
34 - run: echo lint
35`
36
37func ann(level pb.AnnotationLevel, step int32, msg string) *pb.Annotation {
38 return &pb.Annotation{Level: level, Message: msg, StepNumber: step}
39}
40
41func (r *fakeRunner) update(j *pb.Job, steps []*pb.StepUpdate, anns ...*pb.Annotation) error {
42 _, err := r.jobAPI(j).runner.UpdateJob(r.c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: steps, Annotations: anns}))
43 return err
44}
45
46func (r *fakeRunner) summary(j *pb.Job, step int32, md string) error {
47 _, err := r.jobAPI(j).runner.UploadSummary(r.c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: j.GetJobId(), StepNumber: step, Markdown: md}))
48 return err
49}
50
51func (r *fakeRunner) heartbeat(j *pb.Job) {
52 r.c.t.Helper()
53 _, err := r.jobAPI(j).runner.Heartbeat(r.c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
54 require.NoError(r.c.t, err)
55}
56
57func (c *ciHarness) annotations(runID int64, jobID string) *pb.ListAnnotationsResponse {
58 c.t.Helper()
59 res, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: jobID}))
60 require.NoError(c.t, err)
61 return res.Msg
62}
63
64func (c *ciHarness) summaries(jobID string) *pb.GetStepSummariesResponse {
65 c.t.Helper()
66 res, err := c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: jobID}))
67 require.NoError(c.t, err)
68 return res.Msg
69}
70
71func code(err error) connect.Code { return connect.CodeOf(err) }
72
73func TestCIAnnotationsAndSummariesOverTheAPI(t *testing.T) {
74 c := newCIHarness(t, nil)
75 runner := c.registerRunner("alpha", "ubuntu-latest")
76 c.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
77 c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
78 runID := c.runs()[0].GetId()
79
80 ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
81 require.NoError(t, err)
82 assert.Contains(t, ver.Msg.GetFeatures(), "ci.annotations")
83 assert.Contains(t, ver.Msg.GetFeatures(), "ci.summaries")
84
85 a, b := runner.await(), runner.await()
86 runner.heartbeat(a)
87 runner.heartbeat(b)
88 start := now()
89 running := []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start}}
90 require.NoError(t, runner.update(a, running))
91 done := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()}
92
93 notice, warning, failure := pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE
94 t.Run("an annotation of a step that UpdateJob never named is stored", func(t *testing.T) {
95 full := &pb.Annotation{Level: failure, Title: "Vet", Message: "unused variable", File: "main.go", StartLine: 3, EndLine: 4, StartColumn: 2, EndColumn: 8, StepNumber: 7}
96 require.NoError(t, runner.update(a, nil, full, ann(notice, 7, "second")))
97 got := c.annotations(runID, a.GetJobId())
98 require.Len(t, got.GetAnnotations(), 2)
99 assert.Equal(t, a.GetJobId(), got.GetAnnotations()[0].GetJobId())
100 assert.EqualValues(t, full.GetStartLine(), got.GetAnnotations()[0].GetAnnotation().GetStartLine())
101 assert.Equal(t, "Vet", got.GetAnnotations()[0].GetAnnotation().GetTitle())
102 assert.Equal(t, "second", got.GetAnnotations()[1].GetAnnotation().GetMessage(), "in the order the step produced them")
103 assert.False(t, got.GetTruncated())
104 })
105
106 t.Run("replacement per step, a retry changes nothing, a step absent is kept", func(t *testing.T) {
107 require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
108 require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
109 require.NoError(t, runner.update(a, nil, ann(notice, 7, "only one now")))
110 got := c.annotations(runID, a.GetJobId()).GetAnnotations()
111 require.Len(t, got, 2, "step 3 is kept, step 7 holds one annotation")
112 assert.EqualValues(t, 3, got[0].GetAnnotation().GetStepNumber(), "ordered by step")
113 assert.Equal(t, "only one now", got[1].GetAnnotation().GetMessage())
114 })
115
116 t.Run("a COMPLETED step can still have its annotations replaced", func(t *testing.T) {
117 require.NoError(t, runner.update(a, []*pb.StepUpdate{done}, ann(notice, 1, "before")))
118 require.NoError(t, runner.update(a, nil, ann(failure, 1, "after")))
119 require.NoError(t, runner.update(a, []*pb.StepUpdate{done}), "the same step again changes nothing")
120 got := c.annotations(runID, a.GetJobId()).GetAnnotations()
121 assert.Equal(t, "after", got[0].GetAnnotation().GetMessage())
122 assert.Equal(t, failure, got[0].GetAnnotation().GetLevel())
123 // The rule that a completed step cannot change still holds for the step.
124 changed := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: done.GetCompletedAt()}
125 assert.Equal(t, connect.CodeFailedPrecondition, code(runner.update(a, []*pb.StepUpdate{changed})))
126 })
127
128 t.Run("an empty list for a step is not a request: only a step present is replaced", func(t *testing.T) {
129 before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
130 require.NoError(t, runner.update(a, nil))
131 assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
132 })
133
134 t.Run("the limits, and a call applied whole or not at all", func(t *testing.T) {
135 before := c.annotations(runID, a.GetJobId()).GetAnnotations()
136 many := func(level pb.AnnotationLevel, n int) []*pb.Annotation {
137 var out []*pb.Annotation
138 for range n {
139 out = append(out, ann(level, 9, "m"))
140 }
141 return out
142 }
143 require.NoError(t, runner.update(a, nil, many(warning, 10)...), "ten of a level is allowed")
144 require.NoError(t, runner.update(a, nil, append(many(warning, 10), many(failure, 10)...)...), "the limit is per level")
145 bad := map[string][]*pb.Annotation{
146 "eleven of a level": many(notice, 11),
147 "no level": {{Message: "m", StepNumber: 1}},
148 "no message": {ann(notice, 1, "")},
149 "message too long": {ann(notice, 1, strings.Repeat("é", 4097))},
150 "title too long": {{Level: notice, Message: "m", Title: strings.Repeat("t", 4097), StepNumber: 1}},
151 "file too long": {{Level: notice, Message: "m", File: strings.Repeat("f", 1025), StepNumber: 1}},
152 "step zero": {ann(notice, 0, "m")},
153 "step beyond 1000": {ann(notice, 1001, "m")},
154 "end before start": {{Level: notice, Message: "m", StartLine: 5, EndLine: 4, StepNumber: 1}},
155 "a good one and bad": {ann(notice, 1, "good"), ann(notice, 1, "")},
156 }
157 for name, anns := range bad {
158 assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, nil, anns...)), name)
159 }
160 // Characters, not bytes: 4096 two-byte characters pass.
161 require.NoError(t, runner.update(a, nil, ann(notice, 11, strings.Repeat("é", 4096))))
162 require.NoError(t, runner.update(a, nil, &pb.Annotation{Level: notice, Message: "m", Title: strings.Repeat("t", 4096), File: strings.Repeat("f", 1024), StepNumber: 11}))
163 // An invalid call stored nothing: steps 1, 3 and 7 are as they were.
164 after := c.annotations(runID, a.GetJobId()).GetAnnotations()
165 require.GreaterOrEqual(t, len(after), len(before))
166 var step1 []string
167 for _, x := range after {
168 if x.GetAnnotation().GetStepNumber() == 1 {
169 step1 = append(step1, x.GetAnnotation().GetMessage())
170 }
171 }
172 assert.Equal(t, []string{"after"}, step1)
173 // A step of a bad call that is valid is not applied either.
174 assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, []*pb.StepUpdate{{Number: 5, State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}}, ann(notice, 1, ""))))
175 for _, s := range c.getRun(runID).GetJobs()[0].GetSteps() {
176 assert.NotEqual(t, int32(5), s.GetNumber())
177 }
178 })
179
180 t.Run("summaries: stored, replaced, removed, bounded", func(t *testing.T) {
181 require.NoError(t, runner.summary(a, 2, "# one"))
182 require.NoError(t, runner.summary(a, 2, "# one"))
183 require.NoError(t, runner.summary(a, 2, "# two"))
184 require.NoError(t, runner.summary(a, 12, "later"))
185 got := c.summaries(a.GetJobId())
186 require.Len(t, got.GetSummaries(), 2)
187 assert.EqualValues(t, 2, got.GetSummaries()[0].GetStepNumber())
188 assert.Equal(t, "# two", got.GetSummaries()[0].GetMarkdown())
189 assert.EqualValues(t, 12, got.GetSummaries()[1].GetStepNumber(), "numeric order")
190 require.NoError(t, runner.summary(a, 12, ""))
191 assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 1, "an empty markdown removes it")
192
193 assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 0, "x")))
194 assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 1001, "x")))
195 assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 2, strings.Repeat("x", 1<<20+1))))
196 require.NoError(t, runner.summary(a, 3, strings.Repeat("x", 1<<20)), "exactly 1 MiB is accepted")
197 assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 2)
198 assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "an invalid call changes nothing")
199 })
200
201 t.Run("at most 20 summaries per job, with truncated", func(t *testing.T) {
202 for i := int32(1); i <= 22; i++ {
203 require.NoError(t, runner.summary(b, i, "s"))
204 }
205 got := c.summaries(b.GetJobId())
206 assert.Len(t, got.GetSummaries(), 20)
207 assert.True(t, got.GetTruncated())
208 assert.EqualValues(t, 20, got.GetSummaries()[19].GetStepNumber())
209 })
210
211 t.Run("the token of another job is refused", func(t *testing.T) {
212 other := runner.jobAPI(b).runner
213 _, err := other.UpdateJob(c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: a.GetJobId(), Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
214 assert.Equal(t, connect.CodePermissionDenied, code(err))
215 _, err = other.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
216 assert.Equal(t, connect.CodePermissionDenied, code(err))
217 _, err = other.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
218 assert.Equal(t, connect.CodePermissionDenied, code(err))
219 // No credential, a runner token and a stored token are not job tokens either.
220 for _, secret := range []string{"", runner.secret, adminToken} {
221 cl := c.api(secret).runner
222 _, err = cl.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
223 assert.Equal(t, connect.CodeUnauthenticated, code(err), secret)
224 }
225 assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown())
226 })
227
228 t.Run("CompleteJob applies annotations with the steps and the outcome", func(t *testing.T) {
229 steps := []*pb.StepUpdate{
230 done,
231 {
232 Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
233 Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now(),
234 },
235 }
236 _, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
237 JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
238 Annotations: []*pb.Annotation{ann(warning, 2, "from complete"), ann(notice, 1, "step one, replaced")},
239 }))
240 require.NoError(t, err)
241 got := c.annotations(runID, a.GetJobId()).GetAnnotations()
242 var msgs []string
243 for _, x := range got {
244 if s := x.GetAnnotation().GetStepNumber(); s == 1 || s == 2 {
245 msgs = append(msgs, x.GetAnnotation().GetMessage())
246 }
247 }
248 assert.Equal(t, []string{"step one, replaced", "from complete"}, msgs)
249
250 job := c.getRun(runID).GetJobs()[0]
251 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[1].GetConclusion())
252 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, job.GetSteps()[1].GetOutcome(), "continue-on-error: failed, then succeeded")
253 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[0].GetOutcome(), "a runner that sent none: read as the conclusion")
254 })
255
256 t.Run("a job that ended rejects annotations and summaries; a retry of CompleteJob changes nothing", func(t *testing.T) {
257 before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
258 err := runner.update(a, nil, ann(notice, 1, "too late"))
259 require.Equal(t, connect.CodeFailedPrecondition, code(err))
260 assert.NotEmpty(t, errDetails(err), "with a JobEnded detail")
261 err = runner.summary(a, 1, "too late")
262 assert.Equal(t, connect.CodeFailedPrecondition, code(err))
263 assert.NotEmpty(t, errDetails(err))
264 res, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
265 JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "ignored")},
266 }))
267 require.NoError(t, err)
268 assert.False(t, res.Msg.GetApplied())
269 assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
270 for _, x := range c.annotations(runID, a.GetJobId()).GetAnnotations() {
271 assert.NotEqual(t, "too late", x.GetAnnotation().GetMessage())
272 assert.NotEqual(t, "ignored", x.GetAnnotation().GetMessage())
273 }
274 assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "still readable after the end")
275 })
276
277 t.Run("reading: the run, one job, a job that is not of the run, access", func(t *testing.T) {
278 all := c.annotations(runID, "")
279 jobs := map[string]int{}
280 for _, x := range all.GetAnnotations() {
281 jobs[x.GetJobId()]++
282 }
283 assert.Len(t, jobs, 1, "job b has none")
284 assert.Empty(t, c.annotations(runID, b.GetJobId()).GetAnnotations())
285
286 _, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: "99-0-0"}))
287 assert.Equal(t, connect.CodeNotFound, code(err))
288 _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, Attempt: 2}))
289 assert.Equal(t, connect.CodeNotFound, code(err))
290 _, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: 99}))
291 assert.Equal(t, connect.CodeNotFound, code(err))
292 _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: "99-0-0"}))
293 assert.Equal(t, connect.CodeNotFound, code(err))
294 _, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId(), Attempt: 3}))
295 assert.Equal(t, connect.CodeNotFound, code(err))
296
297 // The repository is private: an anonymous caller sees nothing of it, a
298 // reader sees everything, as for GetRun.
299 anon := c.api("").run
300 _, err = anon.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
301 assert.Equal(t, connect.CodeNotFound, code(err))
302 _, err = anon.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
303 assert.Equal(t, connect.CodeNotFound, code(err))
304 reader := c.api(c.token(scopes(domain.ScopeRepoRead))).run
305 _, err = reader.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
306 assert.NoError(t, err)
307 _, err = reader.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
308 assert.NoError(t, err)
309 })
310
311 t.Run("the summaries of an ended job stay readable", func(t *testing.T) {
312 _, err := runner.jobAPI(b).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE}))
313 require.NoError(t, err)
314 assert.Len(t, c.summaries(b.GetJobId()).GetSummaries(), 20)
315 })
316}
317
318// errDetails returns the Connect error details of err.
319func errDetails(err error) []*connect.ErrorDetail {
320 var ce *connect.Error
321 if connect.CodeOf(err) == connect.CodeUnknown {
322 return nil
323 }
324 if e, ok := err.(*connect.Error); ok {
325 ce = e
326 }
327 if ce == nil {
328 return nil
329 }
330 return ce.Details()
331}
332
333// The pages: the Annotations box of the run, the annotations at their step on
334// the job page, the summaries rendered under the run, with nothing of the
335// workflow's markup reaching the page as markup.
336func TestWebAnnotationsAndSummaries(t *testing.T) {
337 ci := newCIHarness(t, nil)
338 b := browser{ci.harness}
339 runner := ci.registerRunner("alpha", "ubuntu-latest")
340 _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
341 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
342 }))
343 require.NoError(t, err)
344 sha := ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0], "src/main.go": "package main\n"}, "")
345 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
346
347 job := runner.await()
348 runner.heartbeat(job)
349 start := now()
350 steps := []*pb.StepUpdate{
351 {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
352 {Number: 2, Name: "Run <b>vet</b>", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
353 }
354 require.NoError(t, runner.update(job, steps))
355 _, err = runner.jobAPI(job).runner.AppendLog(ci.ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: job.GetJobId(), Step: 2, Lines: []*pb.LogLine{{Time: now(), Text: "vet"}}}))
356 require.NoError(t, err)
357 evil := `<img src=x onerror=alert(1)>`
358 require.NoError(t, runner.update(job, nil,
359 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet <i>failed</i>", Message: "unused " + evil, File: "src/main.go", StartLine: 1, EndLine: 3, StepNumber: 2},
360 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "outside", File: "../../etc/passwd", StartLine: 1, StepNumber: 2},
361 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "plain notice", StepNumber: 1},
362 ))
363 require.NoError(t, runner.summary(job, 2, strings.Join([]string{
364 "# Results",
365 "",
366 "<script>alert('summary')</script>",
367 "",
368 "[click](javascript:alert(1)) and [ok](https://example.org/x)",
369 "",
370 "",
371 "",
372 "<img src=x onerror=alert(2)>",
373 "",
374 "| a | b |", "|---|---|", "| 1 | 2 |",
375 }, "\n")))
376 _, err = runner.jobAPI(job).runner.CompleteJob(ci.ctx, connect.NewRequest(&pb.CompleteJobRequest{
377 JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
378 }))
379 require.NoError(t, err)
380 ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
381
382 run := b.get("/proj/actions/runs/1", nil)
383 require.Equal(t, 200, run.status, run.body)
384 assert.Contains(t, run.body, "Annotations")
385 assert.Contains(t, run.body, `aria-label="error"`, "the level has a symbol and a word")
386 assert.Contains(t, run.body, `aria-label="warning"`)
387 assert.Contains(t, run.body, "Vet <i>failed</i>", "a title is text")
388 assert.Contains(t, run.body, "unused <img src=x onerror=alert(1)>")
389 assert.NotContains(t, run.body, evil)
390 assert.Contains(t, run.body, "Run <b>vet</b>", "the step name is text")
391 assert.Contains(t, run.body, `href="/proj/blob/`+sha+`/src/main.go#L1-L3"`, "file:line links to the file at the commit")
392 assert.Contains(t, run.body, "src/main.go:1-3")
393 assert.Contains(t, run.body, "../../etc/passwd:1")
394 assert.NotContains(t, run.body, `href="/proj/blob/`+sha+`/../`, "a path that leaves the repository is not a link")
395 assert.NotContains(t, run.body, `etc/passwd:1</a>`)
396 assert.Contains(t, run.body, `href="/proj/actions/runs/1/job/`+job.GetJobId()+`"`, "the job of the annotation")
397
398 // The summary is rendered by the README renderer: markup dropped.
399 assert.Contains(t, run.body, "<h1", "Markdown is rendered")
400 assert.Contains(t, run.body, "Results")
401 assert.Contains(t, run.body, "<table>")
402 assert.Contains(t, run.body, `href="https://example.org/x"`)
403 assert.Contains(t, run.body, `rel="nofollow noopener noreferrer"`)
404 assert.NotContains(t, run.body, "alert('summary')")
405 assert.NotContains(t, run.body, "<script>alert")
406 assert.NotContains(t, run.body, "javascript:")
407 assert.NotContains(t, run.body, "onerror=alert(2)")
408 assert.NotContains(t, run.body, `<img src=x`)
409 assert.NotContains(t, run.body, "evil.example/p.png", "images are never loaded")
410 assert.Contains(t, run.body, `<span class="alt">[pixel]</span>`)
411 assert.Contains(t, run.body, `click`, "the text of a dropped link stays")
412
413 // The job page: each annotation at its step, the steps linking to the log.
414 jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
415 require.Equal(t, 200, jobPage.status, jobPage.body)
416 assert.Contains(t, jobPage.body, `id="job-annotations"`)
417 assert.Contains(t, jobPage.body, "plain notice")
418 assert.Contains(t, jobPage.body, "unused <img src=x onerror=alert(1)>")
419 assert.NotContains(t, jobPage.body, evil)
420 assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused <img"), "by step")
421
422}
423
424// The job page follows the annotations live: a report of the runner replaces the
425// annotations element, and the stream still never carries the log.
426func TestWebJobAnnotationsAreLive(t *testing.T) {
427 ci := newCIHarness(t, nil)
428 runner := ci.registerRunner("alpha", "ubuntu-latest")
429 _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
430 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
431 }))
432 require.NoError(t, err)
433 ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
434 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
435 job := runner.await()
436 stream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
437 runner.heartbeat(job)
438 require.NoError(t, runner.update(job, []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}},
439 ann(pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, 1, "live <b>warning</b>")))
440 deadline := time.After(10 * time.Second)
441 for {
442 select {
443 case ev := <-stream.events:
444 if ev.name == "ci-job" && strings.Contains(ev.data, `id="job-annotations"`) && strings.Contains(ev.data, "live <b>warning</b>") {
445 assert.NotContains(t, ev.data, "<b>warning</b>")
446 return
447 }
448 case <-deadline:
449 t.Fatal("the annotation did not reach the stream of the job")
450 }
451 }
452}
453
454// A run that is not over has no summary block yet, and a run without result has
455// neither box.
456func TestWebRunWithoutAnnotationsOrSummaries(t *testing.T) {
457 ci := newCIHarness(t, nil)
458 b := browser{ci.harness}
459 runner := ci.registerRunner("alpha", "ubuntu-latest")
460 _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
461 Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
462 }))
463 require.NoError(t, err)
464 ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, " lint:")[0]}, "")
465 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
466 queued := b.get("/proj/actions/runs/1", nil)
467 require.Equal(t, 200, queued.status)
468 assert.NotContains(t, queued.body, "Annotations")
469
470 job := runner.await()
471 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello")
472 done := b.get("/proj/actions/runs/1", nil)
473 require.Equal(t, 200, done.status)
474 assert.NotContains(t, done.body, "Annotations")
475 assert.NotContains(t, done.body, `class="summaries"`)
476 page := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
477 require.Equal(t, 200, page.status)
478 assert.Contains(t, page.body, `id="job-annotations"`, "the slot the live updates fill")
479 assert.NotContains(t, page.body, "Annotations <")
480}
481
482// `run view` prints the ANNOTATIONS section and `--summary` the Markdown.
483func TestCLIRunViewAnnotationsAndSummary(t *testing.T) {
484 ci := newCIHarness(t, nil)
485 h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
486 runner := ci.registerRunner("alpha", "ubuntu-latest")
487 ci.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
488 ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
489
490 // A run without annotation prints none.
491 a, b := runner.await(), runner.await()
492 runner.heartbeat(a)
493 runner.heartbeat(b)
494 plain := h.cli(t, adminToken, "run", "view", "proj", "1")
495 require.NoError(t, plain.err, plain.stderr)
496 assert.NotContains(t, plain.stdout, "ANNOTATIONS")
497 none := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
498 require.NoError(t, none.err, none.stderr)
499 assert.Contains(t, none.stderr, "no job summary")
500
501 start := now()
502 steps := []*pb.StepUpdate{
503 {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
504 {Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now()},
505 }
506 require.NoError(t, runner.update(a, steps,
507 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet", Message: "unused\x1b[31m variable", File: "main.go", StartLine: 3, EndLine: 5, StepNumber: 2},
508 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "careful", StepNumber: 2},
509 &pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "fyi", File: "README.md", StepNumber: 1},
510 ))
511 require.NoError(t, runner.summary(a, 2, "# Hello\n\nfrom a\x1b[2J step\n"))
512 require.NoError(t, runner.summary(b, 2, "from b"))
513
514 view := h.cli(t, adminToken, "run", "view", "proj", "1")
515 require.NoError(t, view.err, view.stderr)
516 assert.Contains(t, view.stdout, "\nANNOTATIONS\n")
517 assert.Contains(t, view.stdout, "LEVEL")
518 for _, want := range []string{"error", "Vet: unused", "main.go:3-5", "warning", "careful", "notice", "fyi", "README.md", "build"} {
519 assert.Contains(t, view.stdout, want)
520 }
521 assert.NotContains(t, view.stdout, "\x1b", "control characters of the workflow never reach the terminal")
522 assert.Less(t, strings.Index(view.stdout, "Set up job"), strings.Index(view.stdout, "ANNOTATIONS"), "after the jobs")
523 // Ordered by job, step, production order: notice (step 1) before the two of step 2.
524 assert.Less(t, strings.Index(view.stdout, "fyi"), strings.Index(view.stdout, "Vet: unused"))
525 assert.Less(t, strings.Index(view.stdout, "Vet: unused"), strings.Index(view.stdout, "careful"))
526
527 js := h.cli(t, adminToken, "run", "view", "proj", "1", "--json")
528 require.NoError(t, js.err, js.stderr)
529 d := decode[jsonRunDetail](t, js.stdout)
530 require.Len(t, d.Annotations, 3)
531 assert.Equal(t, jsonAnnotation{
532 Job: "build", JobID: a.GetJobId(), Step: 1, Level: "notice", Message: "fyi", File: "README.md",
533 }, d.Annotations[0])
534 assert.Equal(t, "failure", d.Annotations[1].Level, "the API's name in JSON")
535 assert.EqualValues(t, 5, d.Annotations[1].EndLine)
536 assert.False(t, d.AnnotationsTruncated)
537 assert.Equal(t, "failure", d.Jobs[0].Steps[1].Outcome)
538 assert.Equal(t, "success", d.Jobs[0].Steps[1].Conclusion)
539 assert.Equal(t, "success", d.Jobs[0].Steps[0].Outcome, "none stored: the conclusion")
540
541 // --summary: raw Markdown, a header per job and step when there are several.
542 sum := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
543 require.NoError(t, sum.err, sum.stderr)
544 assert.Contains(t, sum.stdout, "== build: Run make ==\n# Hello\n")
545 assert.Contains(t, sum.stdout, "== lint: step 2 ==\nfrom b\n")
546 assert.NotContains(t, sum.stdout, "\x1b")
547 one := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "lint")
548 require.NoError(t, one.err, one.stderr)
549 assert.Equal(t, "from b\n", one.stdout, "a single summary is printed as it is")
550 assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--log").err)
551 assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--json").err)
552 assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "nope").err)
553}