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 <b>`, "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"><U+202E></span>`)
368 assert.Contains(t, body, "<U+2066>")
369 assert.Contains(t, body, "<U+2069>")
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, "<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}