1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
19)
20
21// AC6: a summary is untrusted Markdown. The renderer is the one of the READMEs:
22// raw HTML is dropped and a javascript: link loses its link.
23func TestSummariesAreRenderedSafely(t *testing.T) {
24 t.Parallel()
25 const jobID = "1-0-0"
26 repo := repoName(t, "proj")
27 job := ports.JobView{CIJob: domain.CIJob{
28 ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build <script>",
29 Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
30 Steps: []domain.CIStep{{Number: 2, Name: "step <b>two</b>", State: domain.CIStepCompleted, Conclusion: domain.CISuccess}},
31 }}
32 md := strings.Join([]string{
33 "# Title",
34 "<script>alert(1)</script>",
35 "",
36 "[x](javascript:alert(1))",
37 "",
38 "[ok](https://example.org/)",
39 "",
40 "<a href=\"javascript:alert(2)\" onclick=\"alert(3)\">raw</a>",
41 "",
42 "[frag](#title)",
43 }, "\n")
44
45 runs := mocks.NewRunService(t)
46 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
47 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
48 Jobs: []ports.JobView{job}, LatestAttempt: 1,
49 }, nil)
50 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
51 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, jobID, int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
52 Summaries: []domain.CIStepSummary{{Step: 2, Markdown: md}, {Step: 7, Markdown: "second"}}, Truncated: true,
53 }, nil)
54 browse := mocks.NewBrowseService(t)
55 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
56 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
57
58 rec := httptest.NewRecorder()
59 srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil))
60 require.Equal(t, http.StatusOK, rec.Code)
61 body := rec.Body.String()
62
63 assert.Contains(t, body, "<h1", "Markdown is rendered")
64 assert.Contains(t, body, `href="https://example.org/"`)
65 assert.Contains(t, body, "second")
66 assert.Contains(t, body, "step <b>two</b>", "the name of a step is text")
67 assert.Contains(t, body, "step 7", "a step the job does not list is named by its number")
68 assert.Contains(t, body, "build <script>", "the job name is text")
69 assert.Contains(t, body, "more summaries than the first 20")
70 assert.Contains(t, body, `href="#md-s1-0-0-2-title"`, "fragments point at the headings of their own summary")
71 assert.Contains(t, body, `id="md-s1-0-0-2-title"`)
72
73 assert.NotContains(t, body, "<script>alert")
74 assert.NotContains(t, body, "javascript:")
75 assert.NotContains(t, body, "onclick")
76 assert.NotContains(t, body, `<a href="javascript`)
77 assert.NotContains(t, body, "alert(1)</", "no script text left as markup")
78 assert.Contains(t, body, ">x</", "the text of the dropped link stays")
79}
80
81func TestAnnotationViews(t *testing.T) {
82 t.Parallel()
83 sha := strings.Repeat("b", 40)
84 jobs := map[string]jobInfo{"1-0-0": {name: "build", href: "/proj/actions/runs/1/job/1-0-0", steps: map[int32]stepInfo{
85 2: {name: "Run vet", start: 4, end: 9},
86 3: {name: "no lines", start: 9, end: 9},
87 }}}
88 list := ports.AnnotationList{Annotations: []ports.JobAnnotation{
89 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationFailure, Title: "T", Message: "m", File: "pkg/a b.go", StartLine: 4, EndLine: 6, Step: 2}},
90 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "w", File: "x.go", StartLine: 1, EndLine: 1, Step: 3}},
91 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "n", Step: 40}},
92 {JobID: "gone", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "x", Step: 1}},
93 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "p", File: "../../etc/passwd", StartLine: 2, Step: 2}},
94 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "a", File: "/etc/passwd", Step: 2}},
95 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "b", File: `dir\file.go`, Step: 2}},
96 {JobID: "1-0-0", Annotation: domain.CIAnnotation{Level: domain.AnnotationNotice, Message: "c", File: "a/./b.go", Step: 2}},
97 }}
98 v := annotationViews("proj", sha, list, jobs)
99 require.Len(t, v, 7, "an annotation of a job the page does not know is skipped")
100
101 assert.Equal(t, "failure", v[0].Kind)
102 assert.Equal(t, "error", v[0].Level)
103 assert.Equal(t, "Run vet", v[0].Step)
104 assert.Equal(t, "/proj/actions/runs/1/job/1-0-0#L4", v[0].StepHref)
105 assert.Equal(t, "pkg/a b.go:4-6", v[0].Where)
106 assert.Equal(t, "/proj/blob/"+sha+"/pkg/a%20b.go#L4-L6", v[0].WhereHref)
107
108 assert.Equal(t, "x.go:1", v[1].Where, "an end line equal to the start is not a range")
109 assert.Equal(t, "/proj/blob/"+sha+"/x.go#L1", v[1].WhereHref)
110 assert.Empty(t, v[1].StepHref, "a step without lines has no anchor")
111
112 assert.Equal(t, "step 40", v[2].Step, "a step the job does not list")
113 assert.Equal(t, "notice", v[2].Level)
114 assert.Empty(t, v[2].Where)
115
116 for i, want := range []string{"../../etc/passwd:2", "/etc/passwd", `dir\file.go`, "a/./b.go"} {
117 assert.Equal(t, want, v[3+i].Where)
118 assert.Empty(t, v[3+i].WhereHref, "%q is not a path of the repository", want)
119 }
120}
121
122func TestRepoPath(t *testing.T) {
123 t.Parallel()
124 for _, ok := range []string{"a", "a/b.go", "dir/with space/x", ".github/workflows/ci.yml", "a..b", "é/ü.go"} {
125 assert.True(t, repoPath(ok), ok)
126 }
127 for _, bad := range []string{"", ".", "..", "../a", "a/..", "a/../b", "/a", "a//b", "a/./b", "a/", `a\b`, "a\x00b", "a\nb", "a\tb"} {
128 assert.False(t, repoPath(bad), "%q", bad)
129 }
130}
131
132func endedJob(t *testing.T, repo domain.RepoName, id string) ports.JobView {
133 t.Helper()
134 return ports.JobView{CIJob: domain.CIJob{
135 ID: id, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "k", Name: "job " + id,
136 Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
137 }}
138}
139
140func runPageServer(t *testing.T, repo domain.RepoName, jobs []ports.JobView, runs *mocks.RunService) *httptest.ResponseRecorder {
141 t.Helper()
142 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
143 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
144 Jobs: jobs, LatestAttempt: 1,
145 }, nil)
146 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil)
147 browse := mocks.NewBrowseService(t)
148 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
149 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: browse, Runs: runs})
150 rec := httptest.NewRecorder()
151 srv.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/proj/actions/runs/1", nil))
152 require.Equal(t, http.StatusOK, rec.Code)
153 return rec
154}
155
156// GO-001: once the render budget is used up no other job is asked for its
157// summaries, and the page says that some are not shown.
158func TestSummariesStopAtTheBudget(t *testing.T) {
159 t.Parallel()
160 repo := repoName(t, "proj")
161 jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")}
162 runs := mocks.NewRunService(t)
163 // The first job takes the whole budget; the other two are never asked
164 // (the mock fails the test on a call it does not expect).
165 full := strings.Repeat("x", int(maxSummaryPageBytes))
166 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
167 Summaries: []domain.CIStepSummary{{Step: 1, Markdown: full}},
168 }, nil).Once()
169 body := runPageServer(t, repo, jobs, runs).Body.String()
170 assert.Contains(t, body, "more summaries are not shown")
171 assert.Contains(t, body, `aria-label="Summary of job 1-0-0"`)
172 assert.NotContains(t, body, `aria-label="Summary of job 1-1-0"`, "no summary of the other jobs")
173}
174
175// The budget handed to the next job is what is left; a summary the store left
176// unread is shown as such, and so are the ones after it.
177func TestSummariesPassWhatIsLeftOfTheBudget(t *testing.T) {
178 t.Parallel()
179 repo := repoName(t, "proj")
180 jobs := []ports.JobView{endedJob(t, repo, "1-0-0"), endedJob(t, repo, "1-1-0"), endedJob(t, repo, "1-2-0")}
181 runs := mocks.NewRunService(t)
182 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
183 Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "hello"}},
184 }, nil).Once()
185 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-1-0", int32(1), maxSummaryPageBytes-5).Return(ports.SummaryList{
186 Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "world"}, {Step: 2, Omitted: true}, {Step: 3, Omitted: true}},
187 }, nil).Once()
188 body := runPageServer(t, repo, jobs, runs).Body.String()
189 assert.Contains(t, body, "hello")
190 assert.Contains(t, body, "world")
191 assert.Equal(t, 2, strings.Count(body, "this summary is not shown"))
192 assert.Contains(t, body, "more summaries are not shown")
193 assert.NotContains(t, body, `aria-label="Summary of job 1-2-0"`, "the third job is not asked once an omission happened")
194}
195
196func TestNoNoteWhenEverythingFits(t *testing.T) {
197 t.Parallel()
198 repo := repoName(t, "proj")
199 runs := mocks.NewRunService(t)
200 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, "1-0-0", int32(1), maxSummaryPageBytes).Return(ports.SummaryList{
201 Summaries: []domain.CIStepSummary{{Step: 1, Markdown: "small"}},
202 }, nil).Once()
203 body := runPageServer(t, repo, []ports.JobView{endedJob(t, repo, "1-0-0")}, runs).Body.String()
204 assert.Contains(t, body, "small")
205 assert.NotContains(t, body, "not shown")
206}
207
208// GO-002: a live job update reads the job and its annotations, never the run;
209// the commit comes from the stream, and a stream without it has the page fetch
210// itself again.
211func TestJobStateDoesNotReadTheRun(t *testing.T) {
212 t.Parallel()
213 const jobID = "1-0-0"
214 repo := repoName(t, "proj")
215 job := endedJob(t, repo, jobID)
216 job.Steps = []domain.CIStep{{Number: 1, Name: "s", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, HasLines: true}}
217 job.Lines = 2
218 sha := strings.Repeat("c", 40)
219
220 runs := mocks.NewRunService(t) // GetRun is not expected
221 runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil)
222 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(0), jobID).Return(ports.AnnotationList{
223 Annotations: []ports.JobAnnotation{{JobID: jobID, Annotation: domain.CIAnnotation{Level: domain.AnnotationWarning, Message: "m", File: "a.go", StartLine: 2, Step: 1}}},
224 }, nil)
225 browse := mocks.NewBrowseService(t)
226 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
227 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs})
228
229 frags, err := (&liveStream{s: srv, repo: "proj", view: "job", job: jobID, sha: sha}).jobState(context.Background())
230 require.NoError(t, err)
231 require.Len(t, frags, 3)
232 assert.Contains(t, frags[2].html, `href="/proj/blob/`+sha+`/a.go#L2"`, "the annotation links to the commit the stream was set up with")
233
234 frags, err = (&liveStream{s: srv, repo: "proj", view: "job", job: jobID}).jobState(context.Background())
235 require.NoError(t, err)
236 assert.Equal(t, gap, frags)
237}