1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16)
17
18// AC1: the commit pages render for the fixtures with the structure of the
19// mockup (golden HTML, normalised).
20func TestWebCommitGolden(t *testing.T) {
21 h := newHarness(t, harnessOpts{})
22 f := h.diffRepo("diffs")
23
24 for _, tt := range []struct{ name, path string }{
25 {"commit-modify", "/diffs/commit/" + f.modify},
26 {"commit-modify-split", "/diffs/commit/" + f.modify + "?view=split"},
27 {"commit-root", "/diffs/commit/" + f.root},
28 {"commit-rename", "/diffs/commit/" + f.rename},
29 {"commit-binary", "/diffs/commit/" + f.binary},
30 {"commit-merge", "/diffs/commit/" + f.merge},
31 {"commit-trojan", "/diffs/commit/" + f.trojan},
32 {"commit-big", "/diffs/commit/" + f.big},
33 } {
34 t.Run(tt.name, func(t *testing.T) {
35 status, hdr, body := h.get(tt.path)
36 require.Equal(t, 200, status)
37 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
38 assertNoInline(t, body)
39 golden(t, tt.name, normalise(body))
40 })
41 }
42}
43
44// The behaviours of the commit pages, asserted on their own.
45func TestWebCommit(t *testing.T) {
46 h := newHarness(t, harnessOpts{})
47 f := h.diffRepo("diffs")
48 get := func(path string, status int) string {
49 t.Helper()
50 st, _, body := h.get(path)
51 require.Equal(t, status, st, path)
52 return body
53 }
54
55 t.Run("commit", func(t *testing.T) {
56 body := get("/diffs/commit/"+f.modify, 200)
57 assert.Contains(t, body, "<h1>modify a file and make a script executable</h1>")
58 assert.Contains(t, body, "The body has a second paragraph.")
59 assert.Contains(t, body, "And <b>html</b> &", "the message is text")
60 assert.NotContains(t, body, "<b>html</b>")
61 assert.Contains(t, body, "mode 100644 → 100755")
62 assert.Contains(t, body, `<td class="m">+</td>`, "added lines keep their marker")
63 assert.Contains(t, body, `<td class="m">-</td>`, "deleted lines keep their marker")
64 assert.Contains(t, body, `data-diff="unified"`)
65 assert.Contains(t, body, `hx-live="this.dataset.diff = sessionStorage`, "the browser remembers the layout when the URL does not choose")
66 assert.Contains(t, body, `<table class="dt uni"`)
67 assert.Contains(t, body, `<table class="dt spl"`, "both layouts are sent, the toggle needs no request")
68 assert.Contains(t, body, `href="/diffs/commit/`+f.modify+`?view=split"`, "the toggle works without scripts")
69 // The abbreviated id opens the same commit.
70 assert.Equal(t, normalise(body), normalise(get("/diffs/commit/"+f.modify[:12], 200)))
71 })
72
73 t.Run("split without scripts", func(t *testing.T) {
74 body := get("/diffs/commit/"+f.modify+"?view=split", 200)
75 assert.Contains(t, body, `data-diff="split"`)
76 assert.NotContains(t, body, `hx-live="this.dataset.diff`, "the URL decides, not the browser memory")
77 assert.Contains(t, body, `class="ln del"`)
78 assert.Contains(t, body, `class="ln add"`)
79 })
80
81 t.Run("root commit", func(t *testing.T) {
82 body := get("/diffs/commit/"+f.root, 200)
83 assert.Contains(t, body, "none (root commit)")
84 assert.NotContains(t, body, `class="m del"`)
85 })
86
87 t.Run("rename delete binary", func(t *testing.T) {
88 body := get("/diffs/commit/"+f.rename, 200)
89 assert.Contains(t, body, "old.txt → docs-old.txt")
90 assert.Contains(t, body, "renamed, 100% similar")
91 assert.Contains(t, body, "gone.txt")
92 bin := get("/diffs/commit/"+f.binary, 200)
93 assert.Contains(t, bin, "binary file: no text diff")
94 assert.NotContains(t, bin, `<table class="dt`)
95 })
96
97 t.Run("merge", func(t *testing.T) {
98 body := get("/diffs/commit/"+f.merge, 200)
99 assert.Contains(t, body, "merge of")
100 assert.Contains(t, body, "diff against the first parent")
101 assert.Equal(t, 2, strings.Count(body, `href="/diffs/commit/`)-strings.Count(body, `href="/diffs/commit/`+f.merge), "two parent links")
102 })
103
104 t.Run("trojan source", func(t *testing.T) {
105 body := get("/diffs/commit/"+f.trojan, 200)
106 for _, bad := range []string{"\u202e", "\u2066", "\u2069", "\u200b", "\u202c"} {
107 assert.NotContains(t, body, bad, "no invisible character reaches the page: %U", []rune(bad))
108 }
109 for _, mark := range []string{"<U+202E>", "<U+2066>", "<U+2069>", "<U+200B>", "<U+202C>"} {
110 assert.Contains(t, body, mark)
111 }
112 assert.Contains(t, body, "holds invisible characters")
113 })
114
115 t.Run("truncated", func(t *testing.T) {
116 body := get("/diffs/commit/"+f.big, 200)
117 assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed")
118 assert.Contains(t, body, "this diff is incomplete")
119 assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed")
120 assert.NotContains(t, body, "line 2000", "no lines of a truncated file")
121 })
122
123 t.Run("not found", func(t *testing.T) {
124 _, _, missing := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
125 for _, p := range []string{
126 "/diffs/commit/0123456789abcdef0123456789abcdef01234567", // no such commit
127 "/diffs/commit/main", "/diffs/commit/abc", "/diffs/commit/ABCDEF0", "/diffs/commit/v1.0.0", // not a commit id
128 "/nope/commit/" + f.root, "/diffs/commit/" + f.root + "/x",
129 } {
130 st, _, body := h.get(p)
131 assert.Equal(t, 404, st, p)
132 assert.NotContains(t, body, "<h1>root", p)
133 }
134 st, _, body := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567")
135 assert.Equal(t, 404, st)
136 assert.Equal(t, normalise(missing), normalise(body))
137 })
138}
139
140// Over 4,000 lines only one layout is sent, the one the URL asks for.
141func TestWebCommitLargeDiffView(t *testing.T) {
142 h := newHarness(t, harnessOpts{})
143 h.seed("large", domain.VisibilityPublic)
144 wt := h.workTree("main", "README")
145 var lines strings.Builder
146 for i := range 1500 {
147 fmt.Fprintf(&lines, "line %d\n", i)
148 }
149 for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
150 require.NoError(t, os.WriteFile(filepath.Join(wt, name), []byte(lines.String()), 0o644))
151 }
152 h.mustGit(wt, "add", ".")
153 h.mustGit(wt, "commit", "-q", "-m", "three big files")
154 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/large.git"), "main")
155 sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
156
157 _, _, unified := h.get("/large/commit/" + sha)
158 assert.True(t, strings.Contains(unified, `<table class="dt uni"`), "unified by default")
159 assert.False(t, strings.Contains(unified, `<table class="dt spl"`), "one layout only")
160
161 _, _, split := h.get("/large/commit/" + sha + "?view=split")
162 assert.True(t, strings.Contains(split, `<table class="dt spl"`), "the split layout is sent")
163 assert.False(t, strings.Contains(split, `<table class="dt uni"`), "and only it")
164 assert.True(t, strings.Contains(split, `<div id="diffs" data-view="split">`))
165}
166
167// AC2: the commit pages of a private repository are the 404 page of a
168// repository that does not exist.
169func TestWebCommitPrivate(t *testing.T) {
170 h := newHarness(t, harnessOpts{})
171 wt := h.workTree("main", "secret.txt")
172 h.createRepo("hidden-one", domain.VisibilityPrivate)
173 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main")
174 sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
175
176 _, _, missing := h.get("/never-existed")
177 for _, p := range []string{
178 "/hidden-one/commit/" + sha, "/hidden-one/commit/" + sha + "?view=split",
179 } {
180 st, hdr, body := h.get(p)
181 assert.Equal(t, 404, st, p)
182 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
183 assert.NotContains(t, body, "hidden-one", p)
184 assert.NotContains(t, body, "secret.txt", p)
185 assert.Equal(t, normalise(missing), normalise(body), p)
186 }
187}
188
189// The hidden lines of a diff: the page has a gap before a hunk and after the
190// last one, and /context serves their rows for both layouts, numbered on both
191// sides; a private repository's are the 404 page.
192func TestWebDiffContext(t *testing.T) {
193 h := newHarness(t, harnessOpts{})
194 wt := t.TempDir()
195 h.mustGit(wt, "init", "-q", "--initial-branch=main")
196 var src strings.Builder
197 for i := 1; i <= 40; i++ {
198 fmt.Fprintf(&src, "line %d <b>\n", i)
199 }
200 h.put(wt, "f.txt", []byte(src.String()), 0o644)
201 h.commitAt(wt, "2026-02-01T10:00:00Z", "forty lines")
202 h.put(wt, "f.txt", []byte(strings.Replace(src.String(), "line 20 <b>", "line twenty", 1)), 0o644)
203 sha := h.commitAt(wt, "2026-02-02T10:00:00Z", "change line 20")
204 h.createRepo("ctx", domain.VisibilityPublic)
205 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
206
207 _, _, page := h.get("/ctx/commit/" + sha)
208 gap := "/ctx/context/" + sha + "?key=g0&n=16&new=1&old=1&path=f.txt"
209 assert.Contains(t, page, gap, "lines 1 to 16 before the hunk (17 to 23)")
210 assert.Contains(t, page, "16 hidden lines")
211 assert.Contains(t, page, "/ctx/context/"+sha+"?key=tail&n=0&new=24&old=24&path=f.txt")
212
213 st, _, body := h.get("/ctx/context/" + sha + "?key=g0&n=16&new=1&old=1&path=f.txt")
214 require.Equal(t, 200, st)
215 assert.Equal(t, 3, strings.Count(body, "line 16 <b>"), "unified once, split on both sides, escaped")
216 assert.NotContains(t, body, "line 17")
217 assert.NotContains(t, body, `class="gap"`, "nothing left")
218
219 st, _, body = h.get("/ctx/context/" + sha + "?key=tail&n=0&new=24&old=24&path=f.txt")
220 require.Equal(t, 200, st)
221 assert.Contains(t, body, "line 40 <b>")
222 assert.NotContains(t, body, "line 23 ")
223
224 for _, bad := range []string{
225 "/ctx/context/" + sha + "?n=1&new=1&old=1", // no path
226 "/ctx/context/" + sha + "?n=1&new=0&old=1&path=f.txt", // line 0
227 "/ctx/context/" + sha + "?n=-1&new=1&old=1&path=f.txt", // negative
228 "/ctx/context/" + sha + "?n=1&new=99&old=99&path=f.txt", // past the end
229 "/ctx/context/" + sha + "?n=1&new=1&old=1&path=nope.txt", // no such file
230 "/ctx/context/" + sha[:7] + "?n=1&new=1&old=1&path=f.txt", // not a full id
231 "/ctx/context/main?n=1&new=1&old=1&path=f.txt", // a ref
232 } {
233 st, _, _ := h.get(bad)
234 assert.Equal(t, 404, st, bad)
235 }
236
237 // Lines as Git numbers them: a lone CR is not a line break; the last line
238 // of a file without a final newline keeps its marker.
239 var cr strings.Builder
240 cr.WriteString("a\rb\n")
241 for i := 2; i <= 30; i++ {
242 fmt.Fprintf(&cr, "row %d\n", i)
243 }
244 h.put(wt, "cr.txt", []byte(cr.String()+"last"), 0o644)
245 h.commitAt(wt, "2026-02-03T10:00:00Z", "cr")
246 h.put(wt, "cr.txt", []byte(strings.Replace(cr.String(), "row 15\n", "row fifteen\n", 1)+"last"), 0o644)
247 crSHA := h.commitAt(wt, "2026-02-04T10:00:00Z", "change row 15")
248 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx.git"), "main")
249 _, _, crPage := h.get("/ctx/commit/" + crSHA)
250 assert.Contains(t, crPage, "11 hidden lines", "lines 1 to 11 before the hunk at 12")
251 st, _, body = h.get("/ctx/context/" + crSHA + "?n=11&new=1&old=1&path=cr.txt")
252 require.Equal(t, 200, st)
253 assert.Contains(t, body, "row 11")
254 assert.NotContains(t, body, "row 12", "a lone CR does not shift the numbers")
255 st, _, body = h.get("/ctx/context/" + crSHA + "?n=0&new=19&old=19&path=cr.txt")
256 require.Equal(t, 200, st)
257 assert.Contains(t, body, "last<span class=\"eol\"", "no newline at the end of the file")
258
259 // A private repository: the 404 page, nothing of the file.
260 h.createRepo("ctx-hidden", domain.VisibilityPrivate)
261 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/ctx-hidden.git"), "main")
262 st, _, body = h.get("/ctx-hidden/context/" + sha + "?n=0&new=1&old=1&path=f.txt")
263 assert.Equal(t, 404, st)
264 assert.NotContains(t, body, "line 1 ")
265}