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