v0.23.0 333c710

web_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"flag"
7	"io"
8	"net/http"
9	"os"
10	"path/filepath"
11	"regexp"
12	"strconv"
13	"strings"
14	"testing"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/require"
18
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests")
24
25// webFixtureReadme exercises the Markdown policy and the outline.
26const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" +
27	"## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n"
28
29// webRepo is the repository of the golden pages: public, with a README, two
30// branches (one with a slash), two tags and a description.
31func (h *harness) webRepo(name string) {
32	h.t.Helper()
33	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{
34		Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic,
35	})
36	require.NoError(h.t, err)
37	wt := h.t.TempDir()
38	h.mustGit(wt, "init", "-q", "--initial-branch=main")
39	commit := func(date, msg string, files map[string]string) {
40		for p, c := range files {
41			require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755))
42			require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644))
43			h.mustGit(wt, "add", p)
44		}
45		env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
46		out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg)
47		require.NoError(h.t, err, out)
48	}
49	commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"})
50	h.mustGit(wt, "tag", "v0.1.0")
51	commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"})
52	h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0")
53	h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
54	commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"})
55	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
56}
57
58// get does a GET and returns the status, the headers and the body.
59func (h *harness) get(path string) (int, http.Header, string) {
60	h.t.Helper()
61	resp, err := http.Get(h.url(path))
62	require.NoError(h.t, err)
63	defer func() { _ = resp.Body.Close() }()
64	b, err := io.ReadAll(resp.Body)
65	require.NoError(h.t, err)
66	return resp.StatusCode, resp.Header, string(b)
67}
68
69var (
70	reSHA40   = regexp.MustCompile(`\b[0-9a-f]{40}\b`)
71	reSHA7    = regexp.MustCompile(`\b[0-9a-f]{7}\b`)
72	reAsset   = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`)
73	reAgo     = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`)
74	reStamp   = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`)
75	reReqID   = regexp.MustCompile(`request id: [0-9a-f]{8}`)
76	reSpaces  = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`)
77	reMarkers = regexp.MustCompile(`>\s*<`)
78)
79
80// normalise makes a page comparable between runs: it hides what depends on
81// the clock and on the commit ids, which hold the commit times.
82func normalise(s string) string {
83	s = reAsset.ReplaceAllString(s, "$1.HASH$2")
84	s = reSHA40.ReplaceAllString(s, "SHA40")
85	s = reSHA7.ReplaceAllString(s, "SHA7")
86	s = reAgo.ReplaceAllString(s, "AGO")
87	s = reStamp.ReplaceAllString(s, "STAMP")
88	s = reReqID.ReplaceAllString(s, "request id: ID")
89	s = reSpaces.ReplaceAllString(s, "\n")
90	s = reMarkers.ReplaceAllString(s, ">\n<")
91	return strings.TrimSpace(s) + "\n"
92}
93
94func golden(t *testing.T, name, got string) {
95	t.Helper()
96	path := filepath.Join("testdata", "web", name+".html")
97	if *updateGolden {
98		require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
99		require.NoError(t, os.WriteFile(path, []byte(got), 0o644))
100		return
101	}
102	want, err := os.ReadFile(path)
103	require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update")
104	assert.Equal(t, string(want), got, name)
105}
106
107// AC1: the pages render for a fixture repository with the structure of the
108// mockup; the HTML is compared after normalising times and ids.
109func TestWebGolden(t *testing.T) {
110	h := newHarness(t, harnessOpts{})
111	h.webRepo("demo")
112	h.seed("quiet", domain.VisibilityPublic) // empty repository
113
114	for _, tt := range []struct {
115		name, path string
116		status     int
117	}{
118		{"home", "/", 200},
119		{"summary", "/demo", 200},
120		{"refs", "/demo/refs", 200},
121		{"empty", "/quiet", 200},
122		{"not-found", "/nope", 404},
123		{"not-found-page", "/demo/nowhere/main", 404},
124	} {
125		t.Run(tt.name, func(t *testing.T) {
126			status, hdr, body := h.get(tt.path)
127			require.Equal(t, tt.status, status)
128			assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
129			assert.Contains(t, hdr.Get("Content-Type"), "text/html")
130			golden(t, tt.name, normalise(body))
131		})
132	}
133}
134
135// AC2: a private repository is invisible on the web: not in the home list,
136// not in the ":" targets, and its URLs give the same 404 page as a missing
137// repository.
138func TestWebPrivateRepoIsInvisible(t *testing.T) {
139	h := newHarness(t, harnessOpts{})
140	h.seed("open", domain.VisibilityPublic, "README.md")
141	h.seed("secret-sauce", domain.VisibilityPrivate, "README.md")
142
143	status, _, home := h.get("/")
144	require.Equal(t, 200, status)
145	assert.Contains(t, home, "open")
146	assert.NotContains(t, home, "secret-sauce")
147
148	_, _, missingBody := h.get("/never-existed")
149	for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} {
150		status, hdr, body := h.get(p)
151		assert.Equal(t, 404, status, p)
152		assert.NotContains(t, body, "secret-sauce", p)
153		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
154		_, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
155		assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p)
156	}
157	// The open repository's pages do not list it either.
158	_, _, summary := h.get("/open")
159	assert.NotContains(t, summary, "secret-sauce")
160}
161
162// AC4, end to end: a README that attacks the page is neutralised.
163func TestWebReadmeIsNeutralised(t *testing.T) {
164	h := newHarness(t, harnessOpts{})
165	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic})
166	require.NoError(t, err)
167	wt := h.t.TempDir()
168	h.mustGit(wt, "init", "-q", "--initial-branch=main")
169	h.writeFile(wt, "README.md", []byte("# <script>alert(1)</script>Evil\n\n"+
170		"<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n![p](https://evil.example/p.png)\n\n"+
171		"<svg onload=alert(1)></svg>\n\n[ok](docs/a.md)\n"))
172	h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main")
173
174	status, _, body := h.get("/evil")
175	require.Equal(t, 200, status)
176	for _, bad := range []string{"alert(1)</script", "onerror", "javascript:", "evil.example", "<svg", "<img"} {
177		assert.NotContains(t, body, bad)
178	}
179	assert.Contains(t, body, `href="/evil/blob/main/docs/a.md"`)
180	assertNoInline(t, body)
181}
182
183var (
184	reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
185	reStyleAttr    = regexp.MustCompile(`(?i)\sstyle\s*=`)
186	reStyleTag     = regexp.MustCompile(`(?i)<style\b`)
187	reImport       = regexp.MustCompile(`(?i)@import|url\(`)
188	reCSSURL       = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
189)
190
191// assertNoInline checks the page has no inline script, style element or
192// style attribute.
193func assertNoInline(t *testing.T, body string) {
194	t.Helper()
195	for _, m := range reInlineScript.FindAllString(body, -1) {
196		assert.Contains(t, m, "src=", "inline script: %s", m)
197	}
198	assert.False(t, reStyleAttr.MatchString(body), "style attribute")
199	assert.False(t, reStyleTag.MatchString(body), "style element")
200}
201
202// AC6: the CSP forbids inline scripts and styles, nothing is loaded from
203// another origin, no page has an inline script or a style attribute, and
204// every page carries the security headers.
205func TestWebContentSecurity(t *testing.T) {
206	h := newHarness(t, harnessOpts{})
207	h.webRepo("demo")
208	h.seed("quiet", domain.VisibilityPublic)
209
210	const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
211	for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
212		status, hdr, body := h.get(p)
213		assert.Contains(t, []int{200, 404}, status, p)
214		assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
215		assert.NotContains(t, hdr.Get("Content-Security-Policy"), "unsafe-inline")
216		assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
217		assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
218		assert.Equal(t, "same-origin", hdr.Get("Referrer-Policy"), p)
219		assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
220		assertNoInline(t, body)
221
222		// Everything the page loads is a path on this origin.
223		for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
224			assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
225		}
226		assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
227		assert.False(t, reImport.MatchString(body), p)
228
229		// Every static asset the page uses is served, immutable, from here.
230		for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) {
231			st, ah, ab := h.get(m[1])
232			assert.Equal(t, 200, st, m[1])
233			assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])
234			assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])
235			assert.NotEmpty(t, ab)
236			if strings.HasSuffix(m[1], ".css") {
237				// The sheet loads its fonts and icons from here, and nothing else.
238				assert.NotContains(t, ab, "@import", m[1])
239				for _, u := range reCSSURL.FindAllStringSubmatch(ab, -1) {
240					assert.True(t, strings.HasPrefix(u[1], "/-/static/"), "%s loads %s", m[1], u[1])
241					st, _, _ := h.get(u[1])
242					assert.Equal(t, 200, st, u[1])
243				}
244			}
245		}
246	}
247
248	// The footer links to the source (AGPL §13).
249	_, hdr, body := h.get("/")
250	assert.Contains(t, body, `<a href="http://example.test/ermite"`)
251
252	// Pages name their speculation rules (prefetch on hover), a static file.
253	rules := strings.Trim(hdr.Get("Speculation-Rules"), `"`)
254	require.True(t, strings.HasPrefix(rules, "/-/static/rules/rules."), rules)
255	st, rh, rb := h.get(rules)
256	assert.Equal(t, 200, st)
257	assert.Equal(t, "application/speculationrules+json", rh.Get("Content-Type"))
258	assert.Contains(t, rb, `"prefetch"`)
259
260	// Unknown, stale or traversing asset names are 404s.
261	for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {
262		st, _, _ := h.get(p)
263		assert.Equal(t, 404, st, p)
264	}
265}
266
267// About, in the summary rail, names the newest tag; with more tags than it
268// dates, it gives their count only.
269func TestWebSummaryLatestTag(t *testing.T) {
270	h := newHarness(t, harnessOpts{})
271	h.webRepo("demo")
272	_, _, body := h.get("/demo")
273	assert.Contains(t, body, `<dt>latest tag</dt>`)
274	assert.Contains(t, body, `<a class="tag-name" href="/demo/tree/v0.2.0" title="add main.go">`)
275	assert.Contains(t, body, `<a class="all-tags" href="/demo/refs">2 tags</a>`)
276	assert.NotContains(t, body, `id="r-tag"`, "no Tags block of its own: the tag is a row of About")
277
278	h.seed("many", domain.VisibilityPublic, "a.txt")
279	wt := t.TempDir()
280	h.mustGit(wt, "clone", "-q", h.urlAuth(adminToken, "/many.git"), ".")
281	for i := range 21 {
282		h.mustGit(wt, "tag", "t"+strconv.Itoa(i))
283	}
284	h.mustGit(wt, "push", "-q", "--tags", "origin")
285	_, _, body = h.get("/many")
286	assert.NotContains(t, body, `class="tag-name"`)
287	assert.Contains(t, body, `<a class="all-tags" href="/many/refs">21 tags</a>`)
288}

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 44

↑↓ moveenter openesc close