1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "html"
7 "net/http"
8 "net/url"
9 "regexp"
10 "strings"
11 "testing"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// mrRepo is a public repository "proj" with README.md on main and a branch
21// feat/search that is one commit ahead of it (search.go, three lines).
22type mrRepo struct {
23 *cliHarness
24 wt, url string
25}
26
27func newMRRepo(t *testing.T, vis domain.Visibility) *mrRepo {
28 t.Helper()
29 c := newCLIHarness(t)
30 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createParams(c.harness, "proj", vis))
31 require.NoError(t, err)
32 r := &mrRepo{cliHarness: c, wt: c.workTree("main", "README.md"), url: c.urlAuth(adminToken, "/proj.git")}
33 c.mustGit(r.wt, "push", "-q", r.url, "main")
34 c.mustGit(r.wt, "checkout", "-q", "-b", "feat/search")
35 c.writeFile(r.wt, "search.go", []byte("package search\n\nfunc Find() {}\n"))
36 c.mustGit(r.wt, "push", "-q", r.url, "feat/search")
37 return r
38}
39
40func (r *mrRepo) headOf(branch string) string { return r.bareGit("proj", "rev-parse", branch) }
41
42// form posts url-encoded values as the signed-in owner, from this site.
43func (b browser) form(path, token string, values url.Values) page {
44 b.h.t.Helper()
45 return b.do("POST", path, post(token), values.Encode())
46}
47
48var reHeadField = regexp.MustCompile(`name="head_id" value="([0-9a-f]{40})"`)
49
50// formHead reads the head the merge form of a page carries.
51func formHead(t *testing.T, body string) string {
52 t.Helper()
53 m := reHeadField.FindStringSubmatch(body)
54 require.Len(t, m, 2, "the page has a merge form")
55 return m[1]
56}
57
58// AC7: the pages as an anonymous visitor sees them on a public repository:
59// read-only, no forms, public cache headers; the same pages signed in carry
60// the forms and the private headers.
61func TestWebMergeRequestPagesRender(t *testing.T) {
62 r := newMRRepo(t, domain.VisibilityPublic)
63 b := browser{r.harness}
64 token := r.signIn(t)
65
66 // The compare page: anonymous reads, the owner gets the form.
67 anon := b.get("/proj/compare/feat/search", nil)
68 require.Equal(t, 200, anon.status, anon.body)
69 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
70 assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
71 assert.Contains(t, anon.body, "feat/search")
72 assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit")
73 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
74 assert.NotContains(t, anon.body, `<form class="cform create"`)
75 assertNoInline(t, anon.body)
76 owner := b.get("/proj/compare/feat/search", as(token))
77 require.Equal(t, 200, owner.status)
78 assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
79 assert.Contains(t, owner.hdr.Values("Vary"), "Cookie")
80 assert.Contains(t, owner.body, `<form class="cform create" method="post" action="/proj/merge-requests">`)
81 assert.Contains(t, owner.body, `name="source" value="feat/search"`)
82 assert.Contains(t, owner.body, `name="target" value="main"`)
83 assert.Equal(t, 404, b.get("/proj/compare/nope", nil).status)
84 assert.Equal(t, 404, b.get("/proj/compare/main/extra", nil).status)
85 main := b.get("/proj/compare/main", nil)
86 assert.Equal(t, 200, main.status)
87 assert.Contains(t, main.body, "nothing to compare")
88
89 // An empty list, with the way to start one.
90 list := b.get("/proj/merge-requests", nil)
91 require.Equal(t, 200, list.status)
92 assert.Contains(t, list.body, "no open merge requests")
93 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
94 assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")
95 assert.NotContains(t, strings.ToLower(list.body), "proposal")
96
97 // Open one from the compare page.
98 res := b.form("/proj/merge-requests", token, url.Values{
99 "source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},
100 "description": {"Finds things.\r\nSee https://example.org/spec) and javascript:alert(1)"},
101 })
102 require.Equal(t, http.StatusSeeOther, res.status, res.body)
103 assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))
104 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
105 head := r.headOf("feat/search")
106
107 // The same pair twice is refused with a page, and nothing is created.
108 dup := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"dup"}})
109 assert.Equal(t, http.StatusConflict, dup.status)
110 assert.Equal(t, "no-store", dup.hdr.Get("Cache-Control"))
111 assert.Contains(t, dup.body, "already exists")
112 bad := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"bidi\u202etitle"}})
113 assert.Equal(t, http.StatusBadRequest, bad.status)
114 assert.NotContains(t, bad.body, "\u202e")
115
116 // The compare page now points at it.
117 assert.Contains(t, b.get("/proj/compare/feat/search", as(token)).body, `href="/proj/merge-requests/1"`)
118 assert.NotContains(t, b.get("/proj/compare/feat/search", as(token)).body, `<form class="cform create"`)
119
120 // The list.
121 list = b.get("/proj/merge-requests", nil)
122 assert.Contains(t, list.body, `<a href="/proj/merge-requests/1">Add <b>search</b></a>`)
123 assert.Contains(t, list.body, "feat/search → main")
124 assert.Equal(t, "no-cache", list.hdr.Get("Cache-Control"))
125 assertNoInline(t, list.body)
126 assert.Equal(t, "private, no-store", b.get("/proj/merge-requests", as(token)).hdr.Get("Cache-Control"))
127
128 // The page, anonymous: read-only.
129 mr := b.get("/proj/merge-requests/1", nil)
130 require.Equal(t, 200, mr.status, mr.body)
131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
132 assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
133 assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>")
134 assert.NotContains(t, mr.body, "<b>search</b>")
135 assert.Contains(t, mr.body, "feat/search")
136 assert.Contains(t, mr.body, "package search</td>")
137 assert.Contains(t, mr.body, "no checks")
138 assert.Contains(t, mr.body, "fast-forward, squash")
139 assert.Contains(t, mr.body, `<a href="https://example.org/spec" rel="noopener nofollow ugc">https://example.org/spec</a>)`, "the address is linked, not its closing bracket")
140 assert.NotContains(t, mr.body, `href="javascript:`)
141 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
142 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
143 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
144 assert.NotContains(t, mr.body, ">Merge</button>")
145 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
146 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
147 assertNoInline(t, mr.body)
148 assert.Equal(t, 404, b.get("/proj/merge-requests/1/since", nil).status, "the read position is the owner's")
149 assert.Equal(t, 404, b.get("/proj/merge-requests/9", nil).status)
150 assert.Equal(t, 404, b.get("/proj/merge-requests/01", nil).status)
151 assert.Equal(t, 404, b.get("/proj/merge-requests/x", nil).status)
152
153 // Signed in: forms, the merge box with the head and the CLI lines.
154 own := b.get("/proj/merge-requests/1", as(token))
155 require.Equal(t, 200, own.status)
156 assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
157 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
158 assert.Equal(t, head, formHead(t, own.body))
159 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
160 assert.Contains(t, own.body, `<option value="fast-forward">`)
161 assert.Contains(t, own.body, `<option value="squash">`)
162 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
163 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`)
164 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
165 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
166 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
168 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
169 assertNoInline(t, own.body)
170
171 // Reading is safe: GET marked nothing as read.
172 assert.NotContains(t, own.body, "since your last read")
173 show := r.cli(t, adminToken, "mr", "show", "proj", "1", "--json").ok(t)
174 assert.Contains(t, show.stdout, `"last_read_id": ""`)
175}
176
177// A private repository is invisible to the anonymous visitor on every merge
178// request page, and the owner sees it.
179func TestWebMergeRequestPrivate(t *testing.T) {
180 r := newMRRepo(t, domain.VisibilityPrivate)
181 b := browser{r.harness}
182 token := r.signIn(t)
183 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret plan").ok(t)
184
185 missing := b.get("/nothere/merge-requests/1", nil)
186 for _, p := range []string{
187 "/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search",
188 } {
189 got := b.get(p, nil)
190 assert.Equal(t, 404, got.status, p)
191 assert.NotContains(t, got.body, "Secret plan", p)
192 assert.NotContains(t, got.body, "search.go", p)
193 }
194 assert.Equal(t, missing.body, b.get("/proj/merge-requests/1", nil).body, "same page as a missing repository")
195 assert.Equal(t, 404, b.get("/-/events?topic=repo:proj&view=mrs", nil).status)
196
197 for _, p := range []string{"/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search"} {
198 got := b.get(p, as(token))
199 assert.Equal(t, 200, got.status, p)
200 assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
201 assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
202 }
203 assert.Contains(t, b.get("/proj/merge-requests", as(token)).body, "Secret plan")
204}
205
206// AC2: every write needs the session and an Origin of this site.
207func TestWebMergeRequestWritesNeedSessionAndOrigin(t *testing.T) {
208 r := newMRRepo(t, domain.VisibilityPublic)
209 b := browser{r.harness}
210 token := r.signIn(t)
211 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
212 head := r.headOf("feat/search")
213
214 paths := map[string]string{
215 "/proj/merge-requests": "source=feat/search&target=main&title=x",
216 "/proj/merge-requests/1/comments": "text=hello",
217 "/proj/merge-requests/1/comments/1/resolve": "resolved=true",
218 "/proj/merge-requests/1/merge": "mode=fast-forward&head_id=" + head,
219 "/proj/merge-requests/1/close": "",
220 "/proj/merge-requests/1/read": "head_id=" + head,
221 }
222 for path, body := range paths {
223 noOrigin := b.do("POST", path, as(token), body)
224 assert.Equal(t, 403, noOrigin.status, path+" without Origin")
225 assert.Equal(t, "no-store", noOrigin.hdr.Get("Cache-Control"))
226 foreign := b.do("POST", path, map[string]string{"Cookie": sessionCookie + "=" + token, "Origin": "https://evil.example"}, body)
227 assert.Equal(t, 403, foreign.status, path+" foreign Origin")
228 anon := b.do("POST", path, map[string]string{"Origin": publicOrigin}, body)
229 assert.Equal(t, 403, anon.status, path+" without a session")
230 }
231 // Nothing happened.
232 out := r.cli(t, "", "mr", "show", "proj", "1", "--json").ok(t).stdout
233 assert.Contains(t, out, `"state": "open"`)
234 assert.NotContains(t, out, "hello")
235 assert.Equal(t, "", strings.TrimSpace(r.bareGit("proj", "rev-list", "main..feat/search", "--not", "feat/search")))
236
237 // A GET on a write route is not a page.
238 assert.Equal(t, 404, b.get("/proj/merge-requests/1/merge", as(token)).status)
239 assert.Equal(t, 404, b.get("/proj/merge-requests/1/close", as(token)).status)
240}
241
242var reNoteID = regexp.MustCompile(`id="c(\d+)"`)
243
244// The review of one request, in the browser's words: a note on a line, a
245// general one, resolve, since the last read after a push, a stale head, the
246// fast-forward merge.
247func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
248 r := newMRRepo(t, domain.VisibilityPublic)
249 b := browser{r.harness}
250 token := r.signIn(t)
251 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
252 head1 := r.headOf("feat/search")
253 base := "/proj/merge-requests/1"
254
255 // The form of a line, then the note under that line.
256 line := b.get(base+"?on=new:3:search.go", as(token))
257 require.Equal(t, 200, line.status)
258 assert.Contains(t, line.body, `<form class="cform"`)
259 assert.Contains(t, line.body, `name="path" value="search.go"`)
260 assert.Contains(t, line.body, `name="line" value="3"`)
261 assert.Contains(t, line.body, `name="side" value="new"`)
262 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:99:search.go", as(token)).body, `name="line" value="99"`), "a line the diff lacks has no form")
263 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")
264
265 res := b.form(base+"/comments", token, url.Values{"text": {"Why <i>empty</i>?\r\nSee https://example.org/a."}, "path": {"search.go"}, "line": {"3"}, "side": {"new"}})
266 require.Equal(t, http.StatusSeeOther, res.status, res.body)
267 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
268 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
269 res = b.form(base+"/comments", token, url.Values{"text": {"Looks fine overall."}})
270 require.Equal(t, http.StatusSeeOther, res.status, res.body)
271 assert.Equal(t, base+"#c2", res.hdr.Get("Location"))
272
273 anon := b.get(base, nil)
274 i := strings.Index(anon.body, `<tr class="fnote">`)
275 require.GreaterOrEqual(t, i, 0, "a note row under the line")
276 note := anon.body[i:]
277 assert.Less(t, strings.Index(note, "Why <i>empty</i>?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
278 assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="noopener nofollow ugc">https://example.org/a</a>.`)
279 assert.NotContains(t, anon.body, "<i>empty</i>")
280 assert.Contains(t, anon.body, "Looks fine overall.")
281 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
282 assert.NotContains(t, anon.body, "[resolve]", "a visitor cannot resolve")
283
284 // A comment that breaks a rule is refused with the rule, nothing is lost.
285 bad := b.form(base+"/comments", token, url.Values{"text": {"ok"}, "path": {"search.go"}, "line": {"99"}, "side": {"new"}})
286 assert.Equal(t, http.StatusBadRequest, bad.status)
287 assert.Equal(t, "no-store", bad.hdr.Get("Cache-Control"))
288 assert.Contains(t, bad.body, "search.go")
289 bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"zz"}, "side": {"new"}})
290 assert.Equal(t, http.StatusBadRequest, bad.status)
291 bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"3"}, "side": {"sideways"}})
292 assert.Equal(t, http.StatusBadRequest, bad.status)
293 bad = b.form(base+"/comments", token, url.Values{"text": {"evil\u202etext"}})
294 assert.Equal(t, http.StatusBadRequest, bad.status)
295 assert.NotContains(t, bad.body, "\u202e")
296 assert.Equal(t, 404, b.form("/proj/merge-requests/9/comments", token, url.Values{"text": {"x"}}).status)
297
298 // Resolve and unresolve.
299 own := b.get(base, as(token))
300 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments/1/resolve"`)
301 res = b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"true"}})
302 require.Equal(t, http.StatusSeeOther, res.status, res.body)
303 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
304 own = b.get(base, as(token))
305 assert.Contains(t, own.body, `data-resolved="true"`)
306 assert.Contains(t, own.body, ">Unresolve</button>")
307 assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)
308 assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)
309 assert.Equal(t, 404, b.form(base+"/comments/42/resolve", token, url.Values{"resolved": {"true"}}).status)
310
311 // Mark read, then push: the since page shows the new change only.
312 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
313 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
314 require.Equal(t, http.StatusSeeOther, res.status, res.body)
315 assert.NotContains(t, b.get(base, as(token)).body, ">Mark read</button>", "the head is the one that was read")
316 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
317 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
318 head2 := r.headOf("feat/search")
319
320 all := b.get(base, as(token))
321 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
322 assert.Contains(t, all.body, "since your last read")
323 assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
324 assert.Contains(t, all.body, ">Mark read</button>")
325 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
326 since := b.get(base+"/since", as(token))
327 require.Equal(t, 200, since.status, since.body)
328 assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))
329 assert.Contains(t, since.body, "changes since "+head1[:7])
330 assert.Contains(t, since.body, "extra</td>")
331 assert.NotContains(t, since.body, "package search</td>", "only what changed after the read head")
332 assert.Contains(t, since.body, "Why <i>empty</i>?", "the notes are listed below, their lines are not in this diff")
333 assert.Contains(t, since.body, "search.go:3 (new)")
334 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
335
336 // The merge form of the page the owner read carries that head: the push
337 // that came after is not merged unseen.
338 stale := b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head1}})
339 assert.Equal(t, http.StatusConflict, stale.status)
340 assert.Equal(t, "no-store", stale.hdr.Get("Cache-Control"))
341 assert.Contains(t, stale.body, "branch moved")
342 assert.NotEqual(t, head2, r.headOf("main"))
343 assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head2}}).status)
344 assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"squash"}, "head_id": {"abc"}}).status)
345
346 // Fast-forward with the head that was read.
347 fresh := b.get(base, as(token))
348 require.Equal(t, head2, formHead(t, fresh.body))
349 res = b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, fresh.body)}})
350 require.Equal(t, http.StatusSeeOther, res.status, res.body)
351 assert.Equal(t, base, res.hdr.Get("Location"))
352 assert.Equal(t, head2, r.headOf("main"), "the target moved to the head")
353
354 done := b.get(base, nil)
355 assert.Contains(t, done.body, "merged")
356 assert.Contains(t, done.body, "fast-forward")
357 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
358 assert.NotContains(t, done.body, "<form")
359 assert.NotContains(t, b.get(base, as(token)).body, ">Merge</button>", "nothing to merge any more")
360 // Writes on a merged request are refused with a page.
361 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
362 assert.Equal(t, http.StatusConflict, late.status)
363 assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)
364 list := b.get("/proj/merge-requests", nil)
365 assert.Contains(t, list.body, "Merged and closed")
366 assert.Regexp(t, `(?s)Merged and closed.*Add search`, list.body)
367}
368
369// Squash and close, and the order of the list: open first, then the decided.
370func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
371 r := newMRRepo(t, domain.VisibilityPublic)
372 b := browser{r.harness}
373 token := r.signIn(t)
374 head := r.headOf("feat/search")
375 main0 := r.headOf("main")
376
377 // Open from compare with a description; the merge form squashes.
378 res := b.form("/proj/merge-requests", token, url.Values{
379 "source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},
380 })
381 require.Equal(t, http.StatusSeeOther, res.status, res.body)
382 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
383 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
384 require.Equal(t, http.StatusSeeOther, res.status, res.body)
385 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
386 merged := r.headOf("main")
387 assert.NotEqual(t, head, merged, "squash makes a new commit")
388 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")
389 assert.Contains(t, r.bareGit("proj", "cat-file", "-p", "main"), "gpgsig", "signed by the instance key")
390 page := b.get("/proj/merge-requests/1", nil)
391 assert.Contains(t, page.body, "squash")
392 assert.Contains(t, page.body, `href="/proj/commit/`+merged+`"`)
393
394 // A second request: closed.
395 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/other", "main")
396 r.mustGit(r.wt, "pull", "-q", r.url, "main")
397 r.writeFile(r.wt, "other.go", []byte("package other\n"))
398 r.mustGit(r.wt, "push", "-q", r.url, "feat/other")
399 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/third", "feat/other")
400 r.writeFile(r.wt, "third.go", []byte("package third\n"))
401 r.mustGit(r.wt, "push", "-q", r.url, "feat/third")
402 for _, title := range []struct{ src, title string }{{"feat/other", "Other change"}, {"feat/third", "Third change"}} {
403 res = b.form("/proj/merge-requests", token, url.Values{"source": {title.src}, "target": {"main"}, "title": {title.title}})
404 require.Equal(t, http.StatusSeeOther, res.status, res.body)
405 }
406 res = b.form("/proj/merge-requests/2/close", token, nil)
407 require.Equal(t, http.StatusSeeOther, res.status, res.body)
408 assert.Equal(t, "/proj/merge-requests/2", res.hdr.Get("Location"))
409 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
410 closed := b.get("/proj/merge-requests/2", nil)
411 assert.Contains(t, closed.body, `data-state="closed"`)
412 assert.NotContains(t, closed.body, "<form")
413 assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/2/close", token, nil).status, "closing twice says so")
414
415 // Open one first, then merged and closed ones, newest first within each.
416 list := b.get("/proj/merge-requests", nil).body
417 third, squash, other := strings.Index(list, "Third change"), strings.Index(list, "Squash me"), strings.Index(list, "Other change")
418 require.True(t, third >= 0 && squash >= 0 && other >= 0, list)
419 assert.Less(t, third, squash, "the open request comes first")
420 assert.Less(t, third, other)
421 assert.Less(t, strings.Index(list, ">Open<"), strings.Index(list, "Merged and closed"))
422 assert.Less(t, other, squash, "newest first among the decided")
423
424 // A compare page of a branch that is behind says so.
425 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/stale", main0)
426 r.writeFile(r.wt, "stale.go", []byte("package stale\n"))
427 r.mustGit(r.wt, "push", "-q", r.url, "feat/stale")
428 stale := b.get("/proj/compare/feat/stale", nil)
429 assert.Equal(t, 200, stale.status)
430 assert.Contains(t, stale.body, "has moved on")
431}
432
433// The box offers no deletion for the default branch; a fast-forward with the
434// box checked deletes the source.
435func TestWebMergeDeleteBranchBox(t *testing.T) {
436 r := newMRRepo(t, domain.VisibilityPublic)
437 b := browser{r.harness}
438 token := r.signIn(t)
439 // old is an orphan branch: main can be proposed to it, and main stays an
440 // ancestor of feat/search so that it fast-forwards.
441 r.mustGit(r.wt, "checkout", "-q", "--orphan", "old")
442 r.writeFile(r.wt, "old.txt", []byte("old\n")) // commits
443 r.mustGit(r.wt, "push", "-q", r.url, "old")
444
445 for _, mr := range []url.Values{
446 {"source": {"feat/search"}, "target": {"main"}, "title": {"Search"}},
447 {"source": {"main"}, "target": {"old"}, "title": {"Back"}},
448 } {
449 require.Equal(t, http.StatusSeeOther, b.form("/proj/merge-requests", token, mr).status)
450 }
451 assert.NotContains(t, b.get("/proj/merge-requests/2", as(token)).body, `name="delete_branch"`, "never for the default branch")
452
453 one := b.get("/proj/merge-requests/1", as(token))
454 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
455 require.Equal(t, http.StatusSeeOther, res.status, res.body)
456 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
457 done := b.get("/proj/merge-requests/1", nil)
458 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
459 assert.NotContains(t, done.body, `href="/proj/tree/feat/search"`, "no link to a gone branch")
460}
461
462// The comment limit of the API is a message, not a crash.
463func TestWebMergeRequestLimitIsFriendly(t *testing.T) {
464 r := newMRRepo(t, domain.VisibilityPublic)
465 b := browser{r.harness}
466 token := r.signIn(t)
467 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
468 for i := 0; i < 1000; i++ {
469 _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, ports.AddCommentParams{Text: "n"})
470 require.NoError(t, err)
471 }
472 full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}})
473 assert.Equal(t, http.StatusConflict, full.status)
474 assert.Contains(t, full.body, "limit reached")
475 assert.Contains(t, full.body, "1000 comments")
476 assert.Equal(t, "no-store", full.hdr.Get("Cache-Control"))
477}
478
479// Text is escaped everywhere and invisible characters are shown as markers.
480func TestWebMergeRequestEscapesText(t *testing.T) {
481 r := newMRRepo(t, domain.VisibilityPublic)
482 b := browser{r.harness}
483 token := r.signIn(t)
484 res := b.form("/proj/merge-requests", token, url.Values{
485 "source": {"feat/search"}, "target": {"main"}, "title": {`"><script>alert(1)</script>`},
486 "description": {"<img src=x onerror=alert(1)>\nhttp://x.test/\"onmouseover=\"alert(1)"},
487 })
488 require.Equal(t, http.StatusSeeOther, res.status, res.body)
489 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "<script>x</script> & more").ok(t)
490 for _, tok := range []string{"", token} {
491 var h map[string]string
492 if tok != "" {
493 h = as(tok)
494 }
495 body := b.get("/proj/merge-requests/1", h).body
496 assert.NotContains(t, body, "<script>alert")
497 assert.NotContains(t, body, "<img src=x")
498 assert.NotContains(t, body, "<script>x</script>")
499 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
500 assert.Contains(t, body, "<script>x</script> & more")
501 assertNoInline(t, body)
502 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
503 }
504}
505
506// The merge request pages learn of changes over the event stream: a fixed
507// sentence and a link, never the text of a comment or a title.
508func TestWebMergeRequestEvents(t *testing.T) {
509 r := newMRRepo(t, domain.VisibilityPublic)
510 list := r.sse("topic=repo:proj&view=mrs", "")
511 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
512 ev := list.next(t, watchWait)
513 assert.Equal(t, "mr-notice", ev.name)
514 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
515 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
516 assert.NotContains(t, ev.data, "Secret")
517
518 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
519 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
520 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
521 ev = one.next(t, watchWait)
522 assert.Equal(t, "mr-notice", ev.name)
523 assert.Contains(t, ev.data, "a comment was added")
524 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
525 assert.NotContains(t, ev.data, "private words")
526
527 r.writeFile(r.wt, "more.txt", []byte("more\n"))
528 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
529 ev = one.next(t, watchWait)
530 assert.Contains(t, ev.data, "the branch changed")
531
532 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
533 assert.Contains(t, one.next(t, watchWait).data, "was closed")
534
535 // A page of another request hears nothing of this one.
536 for drained := false; !drained; {
537 select {
538 case ev := <-other.events:
539 if !ev.comment && ev.name != "" {
540 t.Fatalf("unexpected event %+v", ev)
541 }
542 default:
543 drained = true
544 }
545 }
546 assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&view=mr&n=1&ref=feat%2Fsearch"`)
547}
548
549func (r *mrRepo) get1(path string) string {
550 r.t.Helper()
551 _, _, body := r.get(path)
552 return body
553}
554
555// A rewritten branch that dropped the commit the owner read says so, and shows
556// the whole change.
557func TestWebMergeRequestSinceAfterForcePush(t *testing.T) {
558 r := newMRRepo(t, domain.VisibilityPublic)
559 b := browser{r.harness}
560 token := r.signIn(t)
561 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
562 base := "/proj/merge-requests/1"
563
564 nothing := b.get(base+"/since", as(token))
565 assert.Equal(t, 200, nothing.status)
566 assert.Contains(t, nothing.body, "nothing was marked read yet")
567
568 head1 := r.headOf("feat/search")
569 require.Equal(t, http.StatusSeeOther, b.form(base+"/read", token, url.Values{"head_id": {head1}}).status)
570 assert.Equal(t, http.StatusBadRequest, b.form(base+"/read", token, url.Values{"head_id": {strings.Repeat("0", 40)}}).status, "not a commit of the repository")
571 r.mustGit(r.wt, "reset", "-q", "--hard", "main")
572 r.writeFile(r.wt, "other.txt", []byte("o\n"))
573 r.mustGit(r.wt, "push", "-q", "--force", r.url, "feat/search")
574 r.bareGit("proj", "reflog", "expire", "--expire=now", "--all")
575 r.bareGit("proj", "gc", "-q", "--prune=now")
576
577 lost := b.get(base+"/since", as(token))
578 require.Equal(t, 200, lost.status)
579 assert.Contains(t, lost.body, "is gone, the branch was rewritten")
580 assert.Contains(t, lost.body, "other.txt", "the whole change is shown")
581}