v0.11.0 0633e44

web_files_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"compress/gzip"
8	"encoding/json"
9	"fmt"
10	"image"
11	"image/color"
12	"image/png"
13	"io"
14	"net/http"
15	"os"
16	"path/filepath"
17	"regexp"
18	"strconv"
19	"strings"
20	"testing"
21
22	"github.com/stretchr/testify/assert"
23	"github.com/stretchr/testify/require"
24
25	"bnema.dev/ermite/internal/domain"
26	"bnema.dev/ermite/internal/ports"
27)
28
29// Trojan Source: a right-to-left override and an isolate around code.
30const webTrojan = "package main\n\nfunc main() {\n\taccess := \"user\u202e \u2066// admin only\u2069 \u2066\"\n\t_ = access\n}\n"
31
32func tinyPNG(t *testing.T) []byte {
33	t.Helper()
34	img := image.NewRGBA(image.Rect(0, 0, 2, 2))
35	img.Set(0, 0, color.RGBA{R: 255, A: 255})
36	var b bytes.Buffer
37	require.NoError(t, png.Encode(&b, img))
38	return b.Bytes()
39}
40
41// webFilesRepo is the repository of the tree, blob and raw tests: files of
42// every kind the pages treat differently, a directory README, a branch and a
43// tag with a slash.
44func (h *harness) webFilesRepo(name string) {
45	h.t.Helper()
46	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{
47		Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic,
48	})
49	require.NoError(h.t, err)
50	wt := h.t.TempDir()
51	h.mustGit(wt, "init", "-q", "--initial-branch=main")
52	h.writeFile(wt, "README.md", []byte("# Files\n\nSee [the guide](docs/guide.md).\n"))
53	h.writeFile(wt, "main.go", []byte("package main\n\nimport \"fmt\"\n\n// main prints.\nfunc main() {\n\tfmt.Println(\"hi <b>\", 42)\n}\n"))
54	h.writeFile(wt, "docs/README.md", []byte("# Docs\n\nBack to [the guide](guide.md) and [root](../main.go).\n"))
55	h.writeFile(wt, "docs/guide.md", []byte("# Guide\n\n## Steps\n\nRun it.\n\n```sh\nmake all\n```\n"))
56	h.writeFile(wt, "src/lib/util.go", []byte("package lib\n"))
57	h.writeFile(wt, "notes.txt", []byte("plain notes\nsecond line\n"))
58	h.writeFile(wt, "big.txt", bytes.Repeat([]byte("0123456789abcdef\n"), 70000)) // 1.1 MiB
59	h.writeFile(wt, "lines.txt", bytes.Repeat([]byte("x\n"), 20005))
60	h.writeFile(wt, "data.bin", []byte("\x00\x01\x02binary\x00"))
61	h.writeFile(wt, "pic.png", tinyPNG(h.t))
62	h.writeFile(wt, "logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script></svg>`))
63	h.writeFile(wt, "page.html", []byte("<script>alert(3)</script>\n"))
64	h.writeFile(wt, "evil.go", []byte(webTrojan))
65	h.writeFile(wt, "empty.txt", nil)
66	h.writeFile(wt, "fake.png", []byte("<html><script>alert(1)</script></html>\n"))
67	h.writeFile(wt, "fake.jpg", []byte("GIF89a\x00\x00binary\x00"))
68	h.mustGit(wt, "tag", "v1.0")
69	h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
70	h.writeFile(wt, "x/y.txt", []byte("on the feature branch\n"))
71	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
72}
73
74// AC1: the tree and blob pages render for a fixture repository; the HTML is
75// compared after normalising times and ids.
76func TestWebFilesGolden(t *testing.T) {
77	h := newHarness(t, harnessOpts{})
78	h.webFilesRepo("files")
79
80	for _, tt := range []struct {
81		name, path string
82		status     int
83	}{
84		{"tree-root", "/files/tree/main", 200},
85		{"tree-subdir", "/files/tree/main/docs", 200},
86		{"tree-nested", "/files/tree/main/src/lib", 200},
87		{"tree-slash-ref", "/files/tree/feature/x/x", 200},
88		{"blob-code", "/files/blob/main/main.go", 200},
89		{"blob-markdown", "/files/blob/main/docs/guide.md", 200},
90		{"blob-text", "/files/blob/main/notes.txt", 200},
91		{"blob-too-large", "/files/blob/main/big.txt", 200},
92		{"blob-binary", "/files/blob/main/data.bin", 200},
93		{"blob-image", "/files/blob/main/pic.png", 200},
94		{"blob-svg", "/files/blob/main/logo.svg", 200},
95		{"blob-trojan", "/files/blob/main/evil.go", 200},
96		{"blob-empty", "/files/blob/main/empty.txt", 200},
97	} {
98		t.Run(tt.name, func(t *testing.T) {
99			status, hdr, body := h.get(tt.path)
100			require.Equal(t, tt.status, status)
101			assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
102			assert.Contains(t, hdr.Get("Content-Type"), "text/html")
103			assertNoInline(t, body)
104			golden(t, tt.name, normalise(body))
105		})
106	}
107}
108
109// AC1, AC2, AC7: ?rows answers the entries of a directory as table rows only,
110// for the Files view to insert under the directory; a private repository is
111// the 404 of a missing one.
112func TestWebTreeRows(t *testing.T) {
113	h := newHarness(t, harnessOpts{})
114	h.webFilesRepo("files")
115	h.seed("secret-sauce", domain.VisibilityPrivate, "dir/a.txt")
116
117	// The page links its directories to their rows.
118	_, _, page := h.get("/files/tree/main")
119	assert.Contains(t, page, `href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
120	assert.Contains(t, page, `<script src="/-/static/js/files.`)
121	assert.NotContains(t, page, "main.go</span>", "the palette lists no file")
122
123	status, hdr, body := h.get("/files/tree/main/docs?rows")
124	require.Equal(t, 200, status)
125	assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
126	assert.Contains(t, hdr.Get("Content-Type"), "text/html")
127	assert.NotContains(t, body, "<html")
128	assert.NotContains(t, body, "<table")
129	assert.Equal(t, 2, strings.Count(body, "<tr"), "one row per entry")
130	assert.Contains(t, body, `<a href="/files/blob/main/docs/README.md">README.md</a>`)
131	assert.Contains(t, body, `<a href="/files/blob/main/docs/guide.md">guide.md</a>`)
132	assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")
133	assertNoInline(t, body)
134
135	// A subdirectory row can be opened in turn.
136	_, _, body = h.get("/files/tree/main/src?rows")
137	assert.Contains(t, body, `href="/files/tree/main/src/lib" data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
138	assert.Contains(t, body, "▸")
139	_, _, body = h.get("/files/tree/main/src/lib?rows")
140	assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)
141	assert.NotContains(t, body, "data-expand")
142
143	// Not found, and private as anonymous: the 404 page, nothing of the rows.
144	for _, p := range []string{"/files/tree/main/nope?rows", "/secret-sauce/tree/main/dir?rows", "/secret-sauce/tree/main?rows"} {
145		status, _, body = h.get(p)
146		assert.Equal(t, 404, status, p)
147		assert.NotContains(t, body, "a.txt", p)
148	}
149}
150
151// ?rows on a directory with more entries than are listed ends with the row
152// that links to the directory page, which says the listing is cut.
153func TestWebTreeRowsTruncated(t *testing.T) {
154	h := newHarness(t, harnessOpts{})
155	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic})
156	require.NoError(t, err)
157	wt := h.t.TempDir()
158	h.mustGit(wt, "init", "-q", "--initial-branch=main")
159	// The limit is domain.MaxTreeEntries: one commit with a few more.
160	n := domain.MaxTreeEntries + 3
161	require.NoError(t, os.MkdirAll(filepath.Join(wt, "many"), 0o755))
162	for i := range n {
163		require.NoError(t, os.WriteFile(filepath.Join(wt, "many", fmt.Sprintf("f%05d", i)), nil, 0o644))
164	}
165	require.NoError(t, os.WriteFile(filepath.Join(wt, "small.txt"), []byte("x\n"), 0o644))
166	h.mustGit(wt, "add", ".")
167	h.mustGit(wt, "commit", "-q", "-m", "wide")
168	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/wide.git"), "main")
169
170	status, _, body := h.get("/wide/tree/main/many?rows")
171	require.Equal(t, 200, status)
172	assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<tr"), "the listed entries and the row that says there are more")
173	assert.Contains(t, body, `<tr class="tree-more"><td colspan="7"><a href="/wide/tree/main/many">more entries: open the directory</a></td></tr>`)
174	assert.Equal(t, 1, strings.Count(body, "tree-more"))
175
176	// A directory that fits has no such row.
177	_, _, body = h.get("/wide/tree?rows")
178	assert.NotContains(t, body, "tree-more")
179}
180
181// Ref names with a slash are found by their longest prefix, and a commit id
182// works where a ref does.
183func TestWebFilesRefs(t *testing.T) {
184	h := newHarness(t, harnessOpts{})
185	h.webFilesRepo("files")
186
187	_, _, body := h.get("/files/tree/feature/x")
188	assert.Contains(t, body, ">x/<", "the directory of the branch feature/x")
189	status, _, body := h.get("/files/blob/feature/x/x/y.txt")
190	require.Equal(t, 200, status)
191	assert.Contains(t, body, "on the feature branch")
192	status, _, body = h.get("/files/blob/v1.0/main.go")
193	require.Equal(t, 200, status, "a tag")
194	assert.Contains(t, body, "package")
195
196	sha := h.bareGit("files", "rev-parse", "main")
197	status, _, body = h.get("/files/blob/" + sha + "/notes.txt")
198	require.Equal(t, 200, status, "a commit id")
199	assert.Contains(t, body, "plain notes")
200	assert.Contains(t, body, "detached")
201	status, _, _ = h.get("/files/tree/" + sha[:7])
202	assert.Equal(t, 200, status, "an abbreviated commit id")
203
204	// Links of a directory README stay under the directory and the ref.
205	_, _, body = h.get("/files/tree/main/docs")
206	assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)
207	assert.Contains(t, body, `href="/files/blob/main/main.go"`)
208}
209
210// AC3: what does not exist is the 404 page, with no hint of why.
211func TestWebFilesNotFound(t *testing.T) {
212	h := newHarness(t, harnessOpts{})
213	h.webFilesRepo("files")
214	h.seed("quiet", domain.VisibilityPublic) // empty repository
215
216	for _, p := range []string{
217		"/files/tree/nope", "/files/tree/main/no/such/dir", "/files/blob/main/nothing.txt", "/files/blob/nope/main.go",
218		"/files/blob/main", "/files/blob/main/docs", "/files/tree/main/main.go", "/files/raw/main/nothing.txt",
219		"/files/raw/main", "/files/raw/main/docs", "/files/blob/ffffffffffffffffffffffffffffffffffffffff/main.go",
220		"/files/blob", "/files/raw", "/files/blob/main/../main.go",
221		"/quiet/tree/main", "/quiet/tree", "/quiet/blob/main/x", "/never/blob/main/x", "/never/raw/main/x",
222	} {
223		status, hdr, body := h.get(p)
224		assert.Equal(t, 404, status, p)
225		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
226		assert.Contains(t, hdr.Get("Content-Type"), "text/html", p)
227		assert.NotContains(t, body, "nothing.txt", p)
228	}
229	status, _, body := h.get("/files/blob/main/nothing.txt")
230	require.Equal(t, 404, status)
231	_, _, other := h.get("/files/tree/nope")
232	assert.Equal(t, normalise(other), normalise(body), "one 404 page for everything")
233}
234
235// AC2: the tree, blob and raw pages of a private repository are the 404 of a
236// missing one.
237func TestWebFilesPrivate(t *testing.T) {
238	h := newHarness(t, harnessOpts{})
239	h.seed("secret-sauce", domain.VisibilityPrivate, "README.md", "a.txt")
240
241	for _, kind := range []string{"tree", "blob", "raw"} {
242		for _, p := range []string{"/secret-sauce/" + kind + "/main", "/secret-sauce/" + kind + "/main/a.txt"} {
243			status, hdr, body := h.get(p)
244			assert.Equal(t, 404, status, p)
245			assert.NotContains(t, body, "secret-sauce", p)
246			assert.NotContains(t, body, "content of", p)
247			assert.NotContains(t, hdr.Get("Content-Type"), "text/plain", p)
248			_, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
249			assert.Equal(t, normalise(missing), normalise(body), "%s is the page of a missing repository", p)
250		}
251	}
252}
253
254func rawGet(t *testing.T, h *harness, path string) (int, http.Header, []byte) {
255	t.Helper()
256	status, hdr, body := h.get(path)
257	return status, hdr, []byte(body)
258}
259
260// The raw route serves text as text/plain, the four image types as images,
261// everything else as an attachment; never as HTML or SVG; and always with
262// nosniff and a CSP that makes the file inert.
263func TestWebRaw(t *testing.T) {
264	h := newHarness(t, harnessOpts{})
265	h.webFilesRepo("files")
266	sha := h.bareGit("files", "rev-parse", "main")
267
268	for _, tt := range []struct {
269		name, path, typ, disposition string
270	}{
271		{"text", "/files/raw/main/notes.txt", "text/plain; charset=utf-8", ""},
272		{"code", "/files/raw/main/main.go", "text/plain; charset=utf-8", ""},
273		{"markdown", "/files/raw/main/README.md", "text/plain; charset=utf-8", ""},
274		{"html is text", "/files/raw/main/page.html", "text/plain; charset=utf-8", ""},
275		{"empty", "/files/raw/main/empty.txt", "text/plain; charset=utf-8", ""},
276		{"large text", "/files/raw/main/big.txt", "text/plain; charset=utf-8", ""},
277		{"png", "/files/raw/main/pic.png", "image/png", ""},
278		{"binary", "/files/raw/main/data.bin", "application/octet-stream", `attachment; filename=data.bin`},
279		{"svg is text", "/files/raw/main/logo.svg", "text/plain; charset=utf-8", ""},
280		{"slash ref", "/files/raw/feature/x/x/y.txt", "text/plain; charset=utf-8", ""},
281		{"tag", "/files/raw/v1.0/main.go", "text/plain; charset=utf-8", ""},
282		{"commit", "/files/raw/" + sha + "/notes.txt", "text/plain; charset=utf-8", ""},
283	} {
284		t.Run(tt.name, func(t *testing.T) {
285			status, hdr, body := rawGet(t, h, tt.path)
286			require.Equal(t, 200, status)
287			assert.Equal(t, tt.typ, hdr.Get("Content-Type"))
288			assert.Equal(t, tt.disposition, hdr.Get("Content-Disposition"))
289			assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"))
290			assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy"))
291			assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"))
292			assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length"))
293			// Even at a full commit id: the repository can turn private.
294			assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
295		})
296	}
297
298	// Content is the file, byte for byte.
299	_, _, body := rawGet(t, h, "/files/raw/main/data.bin")
300	assert.Equal(t, []byte("\x00\x01\x02binary\x00"), body)
301	_, _, body = rawGet(t, h, "/files/raw/main/pic.png")
302	assert.Equal(t, tinyPNG(t), body)
303	_, _, body = rawGet(t, h, "/files/raw/main/big.txt")
304	assert.Len(t, body, 17*70000)
305	_, _, body = rawGet(t, h, "/files/raw/main/evil.go")
306	assert.Equal(t, webTrojan, string(body), "raw is not altered")
307
308	// A file named like an image is not one unless its bytes say so.
309	_, hdr, _ := rawGet(t, h, "/files/raw/main/fake.png")
310	assert.Equal(t, "text/plain; charset=utf-8", hdr.Get("Content-Type"), "the type comes from the bytes, not the name")
311	_, hdr, _ = rawGet(t, h, "/files/raw/main/fake.jpg")
312	assert.Equal(t, "image/gif", hdr.Get("Content-Type"))
313	_, _, page := h.get("/files/blob/main/fake.png")
314	assert.NotContains(t, page, "<script>alert", "shown as code, escaped")
315
316	// HEAD and conditional requests.
317	req, err := http.NewRequest(http.MethodGet, h.url("/files/raw/main/notes.txt"), nil)
318	require.NoError(t, err)
319	resp, err := http.DefaultClient.Do(req)
320	require.NoError(t, err)
321	_ = resp.Body.Close()
322	etag := resp.Header.Get("ETag")
323	require.NotEmpty(t, etag)
324	req.Header.Set("If-None-Match", etag)
325	resp, err = http.DefaultClient.Do(req)
326	require.NoError(t, err)
327	_ = resp.Body.Close()
328	assert.Equal(t, http.StatusNotModified, resp.StatusCode)
329	// A list of validators, weak ones and "*" match as well.
330	for _, inm := range []string{`"other", ` + etag, "W/" + etag, "*"} {
331		req.Header.Set("If-None-Match", inm)
332		resp, err = http.DefaultClient.Do(req)
333		require.NoError(t, err)
334		_ = resp.Body.Close()
335		assert.Equal(t, http.StatusNotModified, resp.StatusCode, inm)
336	}
337	req.Header.Set("If-None-Match", `"other", "another"`)
338	resp, err = http.DefaultClient.Do(req)
339	require.NoError(t, err)
340	_ = resp.Body.Close()
341	assert.Equal(t, http.StatusOK, resp.StatusCode, "no validator matches")
342	assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))
343}
344
345var reLineID = regexp.MustCompile(`<span class="line" id="L(\d+)">`)
346
347// AC1, AC6: the code view numbers its lines with anchors, the page has no
348// inline script or style, and a file that attacks the page is shown inert.
349func TestWebBlobCode(t *testing.T) {
350	h := newHarness(t, harnessOpts{})
351	h.webFilesRepo("files")
352
353	_, _, body := h.get("/files/blob/main/main.go")
354	assert.Equal(t, 8, len(reLineID.FindAllString(body, -1)))
355	assert.Contains(t, body, `<a class="ln" href="#L6">6</a>`)
356	assert.Contains(t, body, `<span class="kn">package</span>`)
357	assert.Contains(t, body, `hi &lt;b&gt;`, "source is escaped")
358	assert.NotContains(t, body, "<b>")
359	assertNoInline(t, body)
360	assert.Regexp(t, `href="/files/raw/main/main.go"`, body)
361
362	// Trojan Source: the controls are shown, not applied.
363	_, _, body = h.get("/files/blob/main/evil.go")
364	for _, r := range []string{"\u202e", "\u2066", "\u2069"} {
365		assert.NotContains(t, body, r, "%+q is not output", r)
366	}
367	assert.Contains(t, body, `<span class="ctl" title="U+202E right-to-left override">&lt;U+202E&gt;</span>`)
368	assert.Contains(t, body, "&lt;U+2066&gt;")
369	assert.Contains(t, body, "&lt;U+2069&gt;")
370
371	// The image and SVG: only the four raster types are images.
372	_, _, body = h.get("/files/blob/main/pic.png")
373	assert.Contains(t, body, `<img src="/files/raw/main/pic.png"`)
374	_, _, body = h.get("/files/blob/main/logo.svg")
375	assert.NotContains(t, body, "<img")
376	assert.NotContains(t, body, "<svg", "the source is escaped, never markup")
377	assert.NotContains(t, body, "<script>alert")
378	assert.Contains(t, body, "&lt;svg")
379	assertNoInline(t, body)
380
381	// Too many lines: the first ones, with a notice.
382	_, _, body = h.get("/files/blob/main/lines.txt")
383	assert.Equal(t, 20000, len(reLineID.FindAllString(body, -1)))
384	assert.Contains(t, body, "20005 lines: showing the first 20000")
385
386	// Markdown: rendered, source under a fold, links under the directory.
387	_, _, body = h.get("/files/blob/main/docs/guide.md")
388	assert.Contains(t, body, `<details class="source">`)
389	assert.Contains(t, body, "<h1 id=\"md-guide\">Guide</h1>")
390	assert.Contains(t, body, `href="#md-steps"`, "outline")
391}
392
393// AC6 on the new pages: the CSP of the site, the security headers, nothing
394// inline and nothing loaded from another origin.
395func TestWebFilesContentSecurity(t *testing.T) {
396	h := newHarness(t, harnessOpts{})
397	h.webFilesRepo("files")
398
399	const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
400	for _, p := range []string{
401		"/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
402		"/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
403		"/files/blob/main/data.bin", "/files/tree/nope",
404	} {
405		_, hdr, body := h.get(p)
406		assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
407		assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
408		assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
409		assertNoInline(t, body)
410		assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
411		assert.False(t, reImport.MatchString(body), p)
412		for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
413			assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
414		}
415	}
416
417	// The theme of the code view is a static file with no colour of its own.
418	_, _, page := h.get("/files/blob/main/main.go")
419	css := regexp.MustCompile(`href="(/-/static/css/[^"]+)"`).FindStringSubmatch(page)
420	require.Len(t, css, 2)
421	_, _, sheet := h.get(css[1])
422	assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
423	assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
424}
425
426// AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped
427// JSON, behind the access rule on every request, even when the list is
428// already cached; a visitor revalidates (no-cache + ETag), the owner's copy is
429// private.
430func TestWebPaths(t *testing.T) {
431	c := newCLIHarness(t)
432	h := c.harness
433	h.webFilesRepo("files")
434	sha := h.bareGit("files", "rev-parse", "main")
435
436	var want []string
437	for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") {
438		if p != "" {
439			want = append(want, p)
440		}
441	}
442	require.NotEmpty(t, want)
443	b := browser{h}
444	path := "/files/paths/" + sha
445	type list struct {
446		Paths     []string `json:"paths"`
447		Truncated bool     `json:"truncated"`
448	}
449	decodeList := func(t *testing.T, body string) list {
450		t.Helper()
451		var l list
452		require.NoError(t, json.Unmarshal([]byte(body), &l))
453		return l
454	}
455	gunzip := func(t *testing.T, body string) string {
456		t.Helper()
457		zr, err := gzip.NewReader(strings.NewReader(body))
458		require.NoError(t, err)
459		out, err := io.ReadAll(zr)
460		require.NoError(t, err)
461		return string(out)
462	}
463
464	// Anonymous with gzip: the cached bytes as they are. The client of the
465	// harness would unzip by itself, so the encoding is asked for explicitly
466	// through a transport that does not.
467	raw := func(hdr map[string]string) page {
468		req, err := http.NewRequest(http.MethodGet, h.url(path), nil)
469		require.NoError(t, err)
470		for k, v := range hdr {
471			req.Header.Set(k, v)
472		}
473		resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
474		require.NoError(t, err)
475		defer func() { _ = resp.Body.Close() }()
476		out, err := io.ReadAll(resp.Body)
477		require.NoError(t, err)
478		return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
479	}
480	gz := raw(map[string]string{"Accept-Encoding": "gzip"})
481	require.Equal(t, 200, gz.status)
482	assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding"))
483	assert.Equal(t, "application/json", gz.hdr.Get("Content-Type"))
484	assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options"))
485	assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control"))
486	assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary"))
487	etag := gz.hdr.Get("ETag")
488	assert.Equal(t, `"paths-`+sha+`-gz"`, etag)
489	got := decodeList(t, gunzip(t, gz.body))
490	assert.Equal(t, want, got.Paths, "git ls-tree -r order")
491	assert.False(t, got.Truncated)
492
493	// Without gzip: plain JSON, the same list.
494	plain := raw(nil)
495	require.Equal(t, 200, plain.status)
496	assert.Empty(t, plain.hdr.Get("Content-Encoding"))
497	assert.Equal(t, want, decodeList(t, plain.body).Paths)
498	assert.Equal(t, `"paths-`+sha+`"`, plain.hdr.Get("ETag"), "another variant, another validator")
499
500	// Revalidation: 304, still through the access rule. The matrix of
501	// validators (variants, weak, "*") is in paths_test.go.
502	nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"})
503	assert.Equal(t, http.StatusNotModified, nm.status)
504	assert.Empty(t, nm.body)
505	assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control"))
506
507	// Only a full commit id: abbreviations, refs, upper case and unknown ids.
508	for _, p := range []string{
509		"/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha),
510		"/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha,
511	} {
512		assert.Equal(t, 404, b.get(p, nil).status, p)
513	}
514
515	// Another repository does not serve a commit it does not have, though
516	// the list of that commit is cached.
517	h.seed("other", domain.VisibilityPublic, "x.txt")
518	assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status)
519
520	// The owner: private, never stored; the list is the same.
521	token := c.signIn(t)
522	own := b.get(path, as(token))
523	require.Equal(t, 200, own.status)
524	assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
525	assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
526	assert.Equal(t, want, decodeList(t, own.body).Paths)
527
528	// The repository turns private: the list is cached on the server, and
529	// anonymous gets the 404 of a missing repository, as the owner still
530	// reads it.
531	priv := domain.VisibilityPrivate
532	_, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), ports.UpdateRepoParams{Visibility: &priv})
533	require.NoError(t, err)
534	for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} {
535		p := b.get(path, hdr)
536		assert.Equal(t, 404, p.status)
537		assert.NotContains(t, p.body, "README.md")
538		assert.NotContains(t, p.hdr.Get("Content-Type"), "json")
539	}
540	assert.Equal(t, 200, b.get(path, as(token)).status)
541}
542
543// AC3, AC4: the Files page carries the finder and the data it needs, on the
544// commit the page shows, however the URL spelled it; the other pages carry
545// the paths of their commit and no finder.
546func TestWebFinderPage(t *testing.T) {
547	h := newHarness(t, harnessOpts{})
548	h.webFilesRepo("files")
549	sha := h.bareGit("files", "rev-parse", "main")
550	attr := func(body, name string) string {
551		m := regexp.MustCompile(`<body[^>]*\b` + name + `="([^"]*)"`).FindStringSubmatch(body)
552		if m == nil {
553			return "<none>"
554		}
555		return m[1]
556	}
557
558	for _, tt := range []struct{ name, path, paths, blob string }{
559		{"branch", "/files/tree/main", "/files/paths/" + sha, "/files/blob/main"},
560		{"subdirectory", "/files/tree/main/docs", "/files/paths/" + sha, "/files/blob/main"},
561		{"slash ref", "/files/tree/feature/x", "/files/paths/" + h.bareGit("files", "rev-parse", "feature/x"), "/files/blob/feature/x"},
562		{"tag", "/files/tree/v1.0", "/files/paths/" + sha, "/files/blob/v1.0"},
563		{"full id", "/files/tree/" + sha, "/files/paths/" + sha, "/files/blob/" + sha},
564		{"abbreviated id", "/files/tree/" + sha[:7], "/files/paths/" + sha, "/files/blob/" + sha[:7]},
565		{"blob page", "/files/blob/main/main.go", "/files/paths/" + sha, "/files/blob/main"},
566		{"blob at full id", "/files/blob/" + sha + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha},
567		{"blob at abbreviated id", "/files/blob/" + sha[:7] + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha[:7]},
568	} {
569		t.Run(tt.name, func(t *testing.T) {
570			status, _, body := h.get(tt.path)
571			require.Equal(t, 200, status)
572			assert.Equal(t, tt.paths, attr(body, "data-paths"))
573			assert.Equal(t, tt.blob, attr(body, "data-blob"))
574			if strings.Contains(tt.path, "/tree/") {
575				assert.Contains(t, body, `id="finder-q"`)
576				assert.Contains(t, body, `id="finder-list"`)
577				assert.Contains(t, body, `placeholder="Find a file…"`)
578			} else {
579				assert.NotContains(t, body, `id="finder-q"`)
580			}
581		})
582	}
583	// No commit to search: no data.
584	_, _, body := h.get("/nothing-here")
585	assert.Equal(t, "<none>", attr(body, "data-paths"))
586	_, _, body = h.get("/files/refs")
587	assert.Equal(t, "/files/paths/"+sha, attr(body, "data-paths"))
588}

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 43

↑↓ moveenter openesc close