1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19)
20
21// diffFixture is the repository of the commit pages: one commit for each
22// kind of change a diff shows.
23type diffFixture struct {
24 root, modify, rename, binary, merge, trojan, big string
25}
26
27// createRepo creates a public repository for the pages.
28func (h *harness) createRepo(name string, vis domain.Visibility) {
29 h.t.Helper()
30 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Description: "Diffs", Visibility: vis})
31 require.NoError(h.t, err)
32}
33
34// commitAt commits what is staged at a fixed date and returns the id.
35func (h *harness) commitAt(wt, date, msg string) string {
36 h.t.Helper()
37 env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date}
38 out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg)
39 require.NoError(h.t, err, out)
40 return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
41}
42
43func (h *harness) put(wt, name string, data []byte, mode os.FileMode) {
44 h.t.Helper()
45 full := filepath.Join(wt, name)
46 require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755))
47 require.NoError(h.t, os.WriteFile(full, data, mode))
48 require.NoError(h.t, os.Chmod(full, mode))
49 h.mustGit(wt, "add", name)
50}
51
52// diffRepo builds the fixture and pushes it as a public repository.
53func (h *harness) diffRepo(name string) diffFixture {
54 h.t.Helper()
55 wt := h.t.TempDir()
56 h.mustGit(wt, "init", "-q", "--initial-branch=main")
57 var f diffFixture
58
59 h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644)
60 h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644)
61 h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644)
62 h.put(wt, "gone.txt", []byte("bye\n"), 0o644)
63 h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644)
64 f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files")
65
66 h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644)
67 h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755)
68 f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".")
69
70 h.mustGit(wt, "mv", "old.txt", "docs-old.txt")
71 h.mustGit(wt, "rm", "-q", "gone.txt")
72 f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another")
73
74 h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644)
75 f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file")
76
77 // A side branch from the root, merged with a merge commit.
78 h.mustGit(wt, "checkout", "-q", "-b", "side", f.root)
79 h.put(wt, "side.txt", []byte("from the side\n"), 0o644)
80 h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side")
81 h.mustGit(wt, "checkout", "-q", "main")
82 env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"}
83 out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side")
84 require.NoError(h.t, err, out)
85 f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
86
87 h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644)
88 f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c")
89
90 var big strings.Builder
91 for i := range 2600 {
92 fmt.Fprintf(&big, "line %d\n", i)
93 }
94 h.put(wt, "big.txt", []byte(big.String()), 0o644)
95 f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file")
96
97 h.mustGit(wt, "tag", "v1.0.0", f.modify)
98 h.createRepo(name, domain.VisibilityPublic)
99 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags")
100 return f
101}
102
103// longRepo is a repository with more commits than a page lists: 52 on main,
104// one of which adds a.txt and one changes it.
105func (h *harness) longRepo(name string) {
106 h.t.Helper()
107 wt := h.t.TempDir()
108 h.mustGit(wt, "init", "-q", "--initial-branch=main")
109 base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
110 for i := range 52 {
111 switch i {
112 case 3:
113 h.put(wt, "a.txt", []byte("first\n"), 0o644)
114 case 30:
115 h.put(wt, "a.txt", []byte("second\n"), 0o644)
116 default:
117 h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644)
118 }
119 h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i))
120 }
121 h.createRepo(name, domain.VisibilityPublic)
122 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123}
124
125var reOlder = regexp.MustCompile(`<a class="btn" href="([^"]+)" rel="next">`)
126
127// unescape undoes the HTML escaping of an attribute value.
128func unescape(s string) string { return strings.ReplaceAll(s, "&", "&") }
129
130// AC1: the history pages render for the fixtures with the structure of the
131// mockup (golden HTML, normalised).
132func TestWebCommitsGolden(t *testing.T) {
133 h := newHarness(t, harnessOpts{})
134 h.diffRepo("diffs")
135 h.longRepo("long")
136
137 // The second page is reached by the link of the first, as a visitor would.
138 _, _, first := h.get("/long/commits/main")
139 m := reOlder.FindStringSubmatch(first)
140 require.Len(t, m, 2, "the first page links to the older commits")
141 second := unescape(m[1])
142 assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second)
143
144 for _, tt := range []struct{ name, path string }{
145 {"commits-root", "/long/commits/main"},
146 {"commits-page2", second},
147 {"commits-path", "/long/commits/main/a.txt"},
148 {"commits-small", "/diffs/commits"},
149 } {
150 t.Run(tt.name, func(t *testing.T) {
151 status, hdr, body := h.get(tt.path)
152 require.Equal(t, 200, status)
153 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
154 assertNoInline(t, body)
155 golden(t, tt.name, normalise(body))
156 })
157 }
158}
159
160// The behaviours of the history pages, asserted on their own.
161func TestWebCommits(t *testing.T) {
162 h := newHarness(t, harnessOpts{})
163 f := h.diffRepo("diffs")
164 h.longRepo("long")
165 get := func(path string, status int) string {
166 t.Helper()
167 st, _, body := h.get(path)
168 require.Equal(t, status, st, path)
169 return body
170 }
171
172 t.Run("history", func(t *testing.T) {
173 first := get("/long/commits/main", 200)
174 assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175 assert.Contains(t, first, "commit number 51")
176 assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")
177 assert.Contains(t, first, `<span class="btn" aria-disabled="true">← Newest</span>`)
178 second := unescape(reOlder.FindStringSubmatch(first)[1])
179 page2 := get(second, 200)
180 assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181 assert.Contains(t, page2, `href="/long/commits/main">← Newest</a>`)
182 assert.Contains(t, page2, `<span class="btn" aria-disabled="true">Older →</span>`)
183 get("/long/commits/main?page=garbage", 404)
184 })
185
186 t.Run("path filter", func(t *testing.T) {
187 body := get("/long/commits/main/a.txt", 200)
188 assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`))
189 assert.Contains(t, body, "history of <code>a.txt</code> at main")
190 assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file")
191 get("/long/commits/main/never/existed.txt", 404)
192 })
193
194 t.Run("refs", func(t *testing.T) {
195 // A tag, a branch, a commit id, and a ref with a slash.
196 assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197 assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198 assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")
199 assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill tag"><span class="sr">tag </span>v1.0.0</span>`)
200 assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill">merge</span>`)
201 get("/diffs/commits/nope", 404)
202 get("/diffs/commits/mai", 404)
203 get("/nope/commits/main", 404)
204 })
205}
206
207// AC2: the history pages of a private repository are the 404 page of a
208// repository that does not exist.
209func TestWebCommitsPrivate(t *testing.T) {
210 h := newHarness(t, harnessOpts{})
211 wt := h.workTree("main", "secret.txt")
212 h.createRepo("hidden-one", domain.VisibilityPrivate)
213 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
214
215 _, _, missing := h.get("/never-existed")
216 for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} {
217 st, hdr, body := h.get(p)
218 assert.Equal(t, 404, st, p)
219 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
220 assert.NotContains(t, body, "hidden-one", p)
221 assert.NotContains(t, body, "secret.txt", p)
222 assert.Equal(t, normalise(missing), normalise(body), p)
223 }
224}