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\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}