// SPDX-License-Identifier: AGPL-3.0-or-later package app import ( "flag" "io" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" ) var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") // webFixtureReadme exercises the Markdown policy and the outline. const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" + "## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n" // webRepo is the repository of the golden pages: public, with a README, two // branches (one with a slash), two tags and a description. func (h *harness) webRepo(name string) { h.t.Helper() _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, }) require.NoError(h.t, err) wt := h.t.TempDir() h.mustGit(wt, "init", "-q", "--initial-branch=main") commit := func(date, msg string, files map[string]string) { for p, c := range files { require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755)) require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644)) h.mustGit(wt, "add", p) } env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date} out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg) require.NoError(h.t, err, out) } commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"}) h.mustGit(wt, "tag", "v0.1.0") commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"}) h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0") h.mustGit(wt, "checkout", "-q", "-b", "feature/x") commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"}) h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags") } // get does a GET and returns the status, the headers and the body. func (h *harness) get(path string) (int, http.Header, string) { h.t.Helper() resp, err := http.Get(h.url(path)) require.NoError(h.t, err) defer func() { _ = resp.Body.Close() }() b, err := io.ReadAll(resp.Body) require.NoError(h.t, err) return resp.StatusCode, resp.Header, string(b) } var ( reSHA40 = regexp.MustCompile(`\b[0-9a-f]{40}\b`) reSHA7 = regexp.MustCompile(`\b[0-9a-f]{7}\b`) reAsset = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`) reAgo = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`) reStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`) reReqID = regexp.MustCompile(`request id: [0-9a-f]{8}`) reSpaces = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`) reMarkers = regexp.MustCompile(`>\s*<`) ) // normalise makes a page comparable between runs: it hides what depends on // the clock and on the commit ids, which hold the commit times. func normalise(s string) string { s = reAsset.ReplaceAllString(s, "$1.HASH$2") s = reSHA40.ReplaceAllString(s, "SHA40") s = reSHA7.ReplaceAllString(s, "SHA7") s = reAgo.ReplaceAllString(s, "AGO") s = reStamp.ReplaceAllString(s, "STAMP") s = reReqID.ReplaceAllString(s, "request id: ID") s = reSpaces.ReplaceAllString(s, "\n") s = reMarkers.ReplaceAllString(s, ">\n<") return strings.TrimSpace(s) + "\n" } func golden(t *testing.T, name, got string) { t.Helper() path := filepath.Join("testdata", "web", name+".html") if *updateGolden { require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte(got), 0o644)) return } want, err := os.ReadFile(path) require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update") assert.Equal(t, string(want), got, name) } // AC1: the pages render for a fixture repository with the structure of the // mockup; the HTML is compared after normalising times and ids. func TestWebGolden(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webRepo("demo") h.seed("quiet", domain.VisibilityPublic) // empty repository for _, tt := range []struct { name, path string status int }{ {"home", "/", 200}, {"summary", "/demo", 200}, {"refs", "/demo/refs", 200}, {"empty", "/quiet", 200}, {"not-found", "/nope", 404}, {"not-found-page", "/demo/nowhere/main", 404}, } { t.Run(tt.name, func(t *testing.T) { status, hdr, body := h.get(tt.path) require.Equal(t, tt.status, status) assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) assert.Contains(t, hdr.Get("Content-Type"), "text/html") golden(t, tt.name, normalise(body)) }) } } // AC2: a private repository is invisible on the web: not in the home list, // not in the ":" targets, and its URLs give the same 404 page as a missing // repository. func TestWebPrivateRepoIsInvisible(t *testing.T) { h := newHarness(t, harnessOpts{}) h.seed("open", domain.VisibilityPublic, "README.md") h.seed("secret-sauce", domain.VisibilityPrivate, "README.md") status, _, home := h.get("/") require.Equal(t, 200, status) assert.Contains(t, home, "open") assert.NotContains(t, home, "secret-sauce") _, _, missingBody := h.get("/never-existed") for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} { status, hdr, body := h.get(p) assert.Equal(t, 404, status, p) assert.NotContains(t, body, "secret-sauce", p) assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1)) assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p) } // The open repository's pages do not list it either. _, _, summary := h.get("/open") assert.NotContains(t, summary, "secret-sauce") } // AC4, end to end: a README that attacks the page is neutralised. func TestWebReadmeIsNeutralised(t *testing.T) { h := newHarness(t, harnessOpts{}) _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) require.NoError(t, err) wt := h.t.TempDir() h.mustGit(wt, "init", "-q", "--initial-branch=main") h.writeFile(wt, "README.md", []byte("# Evil\n\n"+ "\n\n[click](javascript:alert(1))\n\n![p](https://evil.example/p.png)\n\n"+ "\n\n[ok](docs/a.md)\n")) h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main") status, _, body := h.get("/evil") require.Equal(t, 200, status) for _, bad := range []string{"alert(1)]*>`) reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`) reStyleTag = regexp.MustCompile(`(?i)]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) { assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1]) } assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p) assert.False(t, reImport.MatchString(body), p) // Every static asset the page uses is served, immutable, from here. for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) { st, ah, ab := h.get(m[1]) assert.Equal(t, 200, st, m[1]) assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1]) assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1]) assert.NotEmpty(t, ab) if strings.HasSuffix(m[1], ".css") { // The sheet loads its fonts and icons from here, and nothing else. assert.NotContains(t, ab, "@import", m[1]) for _, u := range reCSSURL.FindAllStringSubmatch(ab, -1) { assert.True(t, strings.HasPrefix(u[1], "/-/static/"), "%s loads %s", m[1], u[1]) st, _, _ := h.get(u[1]) assert.Equal(t, 200, st, u[1]) } } } } // The footer links to the source (AGPL ยง13). _, hdr, body := h.get("/") assert.Contains(t, body, ``) assert.Contains(t, body, `

add main.go

`) h.seed("many", domain.VisibilityPublic, "a.txt") wt := t.TempDir() h.mustGit(wt, "clone", "-q", h.urlAuth(adminToken, "/many.git"), ".") for i := range 21 { h.mustGit(wt, "tag", "t"+strconv.Itoa(i)) } h.mustGit(wt, "push", "-q", "--tags", "origin") _, _, body = h.get("/many") assert.NotContains(t, body, `class="latest-tag"`) assert.Contains(t, body, `

21 tags

`) }