v0.31.0 d5e0505

actions_results_test.go RawHistoryFolder

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

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