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 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// mrRepo is a public repository "proj" with README.md on main and a branch
23// feat/search that is one commit ahead of it (search.go, three lines).
24type mrRepo struct {
25 *cliHarness
26 wt, url string
27}
28
29func newMRRepo(t *testing.T, vis domain.Visibility) *mrRepo {
30 t.Helper()
31 c := newCLIHarness(t)
32 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), createParams(c.harness, "proj", vis))
33 require.NoError(t, err)
34 r := &mrRepo{cliHarness: c, wt: c.workTree("main", "README.md"), url: c.urlAuth(adminToken, "/proj.git")}
35 c.mustGit(r.wt, "push", "-q", r.url, "main")
36 c.mustGit(r.wt, "checkout", "-q", "-b", "feat/search")
37 c.writeFile(r.wt, "search.go", []byte("package search\n\nfunc Find() {}\n"))
38 c.mustGit(r.wt, "push", "-q", r.url, "feat/search")
39 return r
40}
41
42func (r *mrRepo) headOf(branch string) string { return r.bareGit("proj", "rev-parse", branch) }
43
44// form posts url-encoded values as the signed-in owner, from this site.
45func (b browser) form(path, token string, values url.Values) page {
46 b.h.t.Helper()
47 return b.do("POST", path, post(token), values.Encode())
48}
49
50var reHeadField = regexp.MustCompile(`name="head_id" value="([0-9a-f]{40})"`)
51
52// formHead reads the head the merge form of a page carries.
53func formHead(t *testing.T, body string) string {
54 t.Helper()
55 m := reHeadField.FindStringSubmatch(body)
56 require.Len(t, m, 2, "the page has a merge form")
57 return m[1]
58}
59
60// AC7: the pages as an anonymous visitor sees them on a public repository:
61// read-only, no forms, public cache headers; the same pages signed in carry
62// the forms and the private headers.
63func TestWebMergeRequestPagesRender(t *testing.T) {
64 r := newMRRepo(t, domain.VisibilityPublic)
65 b := browser{r.harness}
66 token := r.signIn(t)
67
68 // The compare page: anonymous reads, the owner gets the form.
69 anon := b.get("/proj/compare/feat/search", nil)
70 require.Equal(t, 200, anon.status, anon.body)
71 assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control"))
72 assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary"))
73 assert.Contains(t, anon.body, "feat/search")
74 assert.Contains(t, anon.body, `<span class="kn">package</span> search</td>`, "the diff of the single commit")
75 assert.NotContains(t, anon.body, "<form class=\"cform create\"")
76 assert.NotContains(t, anon.body, `<form class="cform create"`)
77 assertNoInline(t, anon.body)
78 owner := b.get("/proj/compare/feat/search", as(token))
79 require.Equal(t, 200, owner.status)
80 assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
81 assert.Contains(t, owner.hdr.Values("Vary"), "Cookie")
82 assert.Contains(t, owner.body, `<form class="cform create" method="post" action="/proj/merge-requests">`)
83 assert.Contains(t, owner.body, `name="source" value="feat/search"`)
84 assert.Contains(t, owner.body, `name="target" value="main"`)
85 assert.Equal(t, 404, b.get("/proj/compare/nope", nil).status)
86 assert.Equal(t, 404, b.get("/proj/compare/main/extra", nil).status)
87 main := b.get("/proj/compare/main", nil)
88 assert.Equal(t, 200, main.status)
89 assert.Contains(t, main.body, "nothing to compare")
90
91 // An empty list, with the way to start one.
92 list := b.get("/proj/merge-requests", nil)
93 require.Equal(t, 200, list.status)
94 assert.Contains(t, list.body, "no open merge requests")
95 assert.Contains(t, list.body, `href="/proj/compare/feat/search"`)
96 assert.Contains(t, list.body, `data-key="m"`, "the section of the nav")
97 assert.NotContains(t, strings.ToLower(list.body), "proposal")
98
99 // Open one from the compare page.
100 res := b.form("/proj/merge-requests", token, url.Values{
101 "source": {"feat/search"}, "target": {"main"}, "title": {"Add <b>search</b>"},
102 "description": {"## Summary\r\n\r\nFinds **things** with `Find()`.\r\n\r\n- See https://example.org/spec) and javascript:alert(1)\r\n- [x](javascript:alert(1)) <script>alert(1)</script>"},
103 })
104 require.Equal(t, http.StatusSeeOther, res.status, res.body)
105 assert.Equal(t, "/proj/merge-requests/1", res.hdr.Get("Location"))
106 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
107 head := r.headOf("feat/search")
108
109 // The same pair twice is refused with a page, and nothing is created.
110 dup := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"dup"}})
111 assert.Equal(t, http.StatusConflict, dup.status)
112 assert.Equal(t, "no-store", dup.hdr.Get("Cache-Control"))
113 assert.Contains(t, dup.body, "already exists")
114 bad := b.form("/proj/merge-requests", token, url.Values{"source": {"feat/search"}, "target": {"main"}, "title": {"bidi\u202etitle"}})
115 assert.Equal(t, http.StatusBadRequest, bad.status)
116 assert.NotContains(t, bad.body, "\u202e")
117
118 // The compare page now points at it.
119 assert.Contains(t, b.get("/proj/compare/feat/search", as(token)).body, `href="/proj/merge-requests/1"`)
120 assert.NotContains(t, b.get("/proj/compare/feat/search", as(token)).body, `<form class="cform create"`)
121
122 // The list.
123 list = b.get("/proj/merge-requests", nil)
124 assert.Contains(t, list.body, `<a href="/proj/merge-requests/1">Add <b>search</b></a>`)
125 assert.Contains(t, list.body, "feat/search → main")
126 assert.Equal(t, "no-cache", list.hdr.Get("Cache-Control"))
127 assertNoInline(t, list.body)
128 assert.Equal(t, "private, no-store", b.get("/proj/merge-requests", as(token)).hdr.Get("Cache-Control"))
129
130 // The page, anonymous: read-only.
131 mr := b.get("/proj/merge-requests/1", nil)
132 require.Equal(t, 200, mr.status, mr.body)
133 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
134 assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
135 assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add <b>search</b></h1>")
136 assert.NotContains(t, mr.body, "<b>search</b>")
137 assert.Contains(t, mr.body, "feat/search")
138 assert.Contains(t, mr.body, `<span class="kn">package</span> search</td>`)
139 assert.Contains(t, mr.body, "no checks")
140 assert.Contains(t, mr.body, "fast-forward, squash")
141 // The description is Markdown, rendered as a README: no raw HTML, no
142 // script URL; its heading ids keep apart from the ids of the page.
143 assert.Contains(t, mr.body, `<article class="mr-desc prose" aria-label="description" hx-ignore>`)
144 assert.Contains(t, mr.body, `<h2 id="mr-desc-summary">Summary</h2>`)
145 assert.Contains(t, mr.body, `<strong>things</strong>`)
146 assert.Contains(t, mr.body, `<code>Find()</code>`)
147 assert.Contains(t, mr.body, `<a href="https://example.org/spec"`, "a bare address is linked")
148 assert.NotContains(t, mr.body, `href="javascript:`)
149 assert.NotContains(t, mr.body, `<script>alert(1)</script>`)
150 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
151 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
152 assert.NotContains(t, mr.body, `id="merge-menu"`)
153 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
154 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
155 assertNoInline(t, mr.body)
156 assert.Equal(t, 404, b.get("/proj/merge-requests/1/since", nil).status, "the read position is the owner's")
157 assert.Equal(t, 404, b.get("/proj/merge-requests/9", nil).status)
158 assert.Equal(t, 404, b.get("/proj/merge-requests/01", nil).status)
159 assert.Equal(t, 404, b.get("/proj/merge-requests/x", nil).status)
160
161 // Signed in: forms, the merge box with the head and the CLI lines.
162 own := b.get("/proj/merge-requests/1", as(token))
163 require.Equal(t, 200, own.status)
164 assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
165 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
166 assert.Equal(t, head, formHead(t, own.body))
167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
168 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
169 assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
170 assert.Contains(t, own.body, `name="mode" value="squash"`)
171 assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
174 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
175 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177 assert.NotContains(t, own.body, "--squash")
178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
181 assertNoInline(t, own.body)
182
183 // Reading is safe: GET marked nothing as read.
184 assert.NotContains(t, own.body, "since your last read")
185 show := r.cli(t, adminToken, "mr", "show", "proj", "1", "--json").ok(t)
186 assert.Contains(t, show.stdout, `"last_read_id": ""`)
187}
188
189// A private repository is invisible to the anonymous visitor on every merge
190// request page, and the owner sees it.
191func TestWebMergeRequestPrivate(t *testing.T) {
192 r := newMRRepo(t, domain.VisibilityPrivate)
193 b := browser{r.harness}
194 token := r.signIn(t)
195 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret plan").ok(t)
196
197 missing := b.get("/nothere/merge-requests/1", nil)
198 for _, p := range []string{
199 "/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search",
200 } {
201 got := b.get(p, nil)
202 assert.Equal(t, 404, got.status, p)
203 assert.NotContains(t, got.body, "Secret plan", p)
204 assert.NotContains(t, got.body, "search.go", p)
205 }
206 assert.Equal(t, missing.body, b.get("/proj/merge-requests/1", nil).body, "same page as a missing repository")
207 assert.Equal(t, 404, b.get("/-/events?topic=repo:proj&view=mrs", nil).status)
208
209 for _, p := range []string{"/proj/merge-requests", "/proj/merge-requests/1", "/proj/merge-requests/1/since", "/proj/compare/feat/search"} {
210 got := b.get(p, as(token))
211 assert.Equal(t, 200, got.status, p)
212 assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
213 assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
214 }
215 assert.Contains(t, b.get("/proj/merge-requests", as(token)).body, "Secret plan")
216}
217
218// AC2: every write needs the session and an Origin of this site.
219func TestWebMergeRequestWritesNeedSessionAndOrigin(t *testing.T) {
220 r := newMRRepo(t, domain.VisibilityPublic)
221 b := browser{r.harness}
222 token := r.signIn(t)
223 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
224 head := r.headOf("feat/search")
225
226 paths := map[string]string{
227 "/proj/merge-requests": "source=feat/search&target=main&title=x",
228 "/proj/merge-requests/1/comments": "text=hello",
229 "/proj/merge-requests/1/comments/1/resolve": "resolved=true",
230 "/proj/merge-requests/1/merge": "mode=fast-forward&head_id=" + head,
231 "/proj/merge-requests/1/close": "",
232 "/proj/merge-requests/1/read": "head_id=" + head,
233 "/proj/merge-requests/1/ready": "",
234 }
235 for path, body := range paths {
236 noOrigin := b.do("POST", path, as(token), body)
237 assert.Equal(t, 403, noOrigin.status, path+" without Origin")
238 assert.Equal(t, "no-store", noOrigin.hdr.Get("Cache-Control"))
239 foreign := b.do("POST", path, map[string]string{"Cookie": sessionCookie + "=" + token, "Origin": "https://evil.example"}, body)
240 assert.Equal(t, 403, foreign.status, path+" foreign Origin")
241 anon := b.do("POST", path, map[string]string{"Origin": publicOrigin}, body)
242 assert.Equal(t, 403, anon.status, path+" without a session")
243 }
244 // Nothing happened.
245 out := r.cli(t, "", "mr", "show", "proj", "1", "--json").ok(t).stdout
246 assert.Contains(t, out, `"state": "open"`)
247 assert.NotContains(t, out, "hello")
248 assert.Equal(t, "", strings.TrimSpace(r.bareGit("proj", "rev-list", "main..feat/search", "--not", "feat/search")))
249
250 // A GET on a write route is not a page.
251 assert.Equal(t, 404, b.get("/proj/merge-requests/1/merge", as(token)).status)
252 assert.Equal(t, 404, b.get("/proj/merge-requests/1/close", as(token)).status)
253}
254
255var reNoteID = regexp.MustCompile(`id="c(\d+)"`)
256
257// The review of one request, in the browser's words: a note on a line, a
258// general one, resolve, since the last read after a push, a stale head, the
259// fast-forward merge.
260func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
261 r := newMRRepo(t, domain.VisibilityPublic)
262 b := browser{r.harness}
263 token := r.signIn(t)
264 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
265 head1 := r.headOf("feat/search")
266 base := "/proj/merge-requests/1"
267
268 // The form of a line, then the note under that line.
269 line := b.get(base+"?on=new:3:search.go", as(token))
270 require.Equal(t, 200, line.status)
271 assert.Contains(t, line.body, `<form class="cform"`)
272 assert.Contains(t, line.body, `name="path" value="search.go"`)
273 assert.Contains(t, line.body, `name="line" value="3"`)
274 assert.Contains(t, line.body, `name="side" value="new"`)
275 assert.Contains(t, line.body, `comment on search.go:3 (new)`, "one line: the title names it")
276 assert.Contains(t, b.get(base+"?on=new:1-3:search.go", as(token)).body, `comment on search.go:1-3 (new)`, "a range: the title names all of it")
277 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")
278 assert.Equal(t, 0, strings.Count(b.get(base+"?on=new:3:search.go", nil).body, `<form`), "nor for a visitor")
279
280 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"}})
281 require.Equal(t, http.StatusSeeOther, res.status, res.body)
282 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
283 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
284 res = b.form(base+"/comments", token, url.Values{"text": {"Looks fine overall."}})
285 require.Equal(t, http.StatusSeeOther, res.status, res.body)
286 assert.Equal(t, base+"#c2", res.hdr.Get("Location"))
287
288 anon := b.get(base, nil)
289 i := strings.Index(anon.body, `<tr class="fnote">`)
290 require.GreaterOrEqual(t, i, 0, "a note row under the line")
291 note := anon.body[i:]
292 assert.Less(t, strings.Index(note, "Why <i>empty</i>?"), strings.Index(note, "</tr>"), "the note sits in the row under its line")
293 assert.Contains(t, anon.body, `<a href="https://example.org/a" rel="nofollow noopener noreferrer">https://example.org/a</a>.`)
294 assert.NotContains(t, anon.body, "<i>empty</i>")
295 assert.Contains(t, anon.body, "Looks fine overall.")
296 assert.Equal(t, 2, len(reNoteID.FindAllString(anon.body, -1)))
297 assert.NotContains(t, anon.body, "[resolve]", "a visitor cannot resolve")
298
299 // A comment that breaks a rule is refused with the rule, nothing is lost.
300 bad := b.form(base+"/comments", token, url.Values{"text": {"ok"}, "path": {"search.go"}, "line": {"99"}, "side": {"new"}})
301 assert.Equal(t, http.StatusBadRequest, bad.status)
302 assert.Equal(t, "no-store", bad.hdr.Get("Cache-Control"))
303 assert.Contains(t, bad.body, "search.go")
304 bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"zz"}, "side": {"new"}})
305 assert.Equal(t, http.StatusBadRequest, bad.status)
306 bad = b.form(base+"/comments", token, url.Values{"text": {"x"}, "path": {"search.go"}, "line": {"3"}, "side": {"sideways"}})
307 assert.Equal(t, http.StatusBadRequest, bad.status)
308 bad = b.form(base+"/comments", token, url.Values{"text": {"evil\u202etext"}})
309 assert.Equal(t, http.StatusBadRequest, bad.status)
310 assert.NotContains(t, bad.body, "\u202e")
311 assert.Equal(t, 404, b.form("/proj/merge-requests/9/comments", token, url.Values{"text": {"x"}}).status)
312
313 // Resolve and unresolve.
314 own := b.get(base, as(token))
315 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments/1/resolve"`)
316 res = b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"true"}})
317 require.Equal(t, http.StatusSeeOther, res.status, res.body)
318 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
319 own = b.get(base, as(token))
320 assert.Contains(t, own.body, `data-resolved="true"`)
321 assert.Contains(t, own.body, ">Unresolve</button>")
322 assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)
323 assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)
324 assert.Equal(t, 404, b.form(base+"/comments/42/resolve", token, url.Values{"resolved": {"true"}}).status)
325
326 // Mark read, then push: the since page shows the new change only.
327 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
328 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
329 require.Equal(t, http.StatusSeeOther, res.status, res.body)
330 assert.NotContains(t, b.get(base, as(token)).body, "data-read>Mark read", "the head is the one that was read")
331 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
332 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
333 head2 := r.headOf("feat/search")
334
335 all := b.get(base, as(token))
336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337 assert.Contains(t, all.body, "since your last read")
338 assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked")
339 assert.Contains(t, all.body, "data-read>Mark read")
340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341 since := b.get(base+"/since", as(token))
342 require.Equal(t, 200, since.status, since.body)
343 assert.Equal(t, "private, no-store", since.hdr.Get("Cache-Control"))
344 assert.Contains(t, since.body, "changes since "+head1[:7])
345 assert.Contains(t, since.body, "extra</td>")
346 assert.NotContains(t, since.body, `<span class="kn">package</span> search</td>`, "only what changed after the read head")
347 assert.Contains(t, since.body, "empty", "the notes are listed below, their lines are not in this diff")
348 assert.NotContains(t, since.body, "<i>", "raw HTML is dropped")
349 assert.Contains(t, since.body, "search.go:3 (new)")
350 assert.NotContains(t, since.body, `<form class="cform"`, "no comment form on this diff")
351
352 // The merge form of the page the owner read carries that head: the push
353 // that came after is not merged unseen.
354 stale := b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head1}})
355 assert.Equal(t, http.StatusConflict, stale.status)
356 assert.Equal(t, "no-store", stale.hdr.Get("Cache-Control"))
357 assert.Contains(t, stale.body, "branch moved")
358 assert.NotEqual(t, head2, r.headOf("main"))
359 assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head2}}).status)
360 assert.Equal(t, http.StatusBadRequest, b.form(base+"/merge", token, url.Values{"mode": {"squash"}, "head_id": {"abc"}}).status)
361
362 // Fast-forward with the head that was read.
363 fresh := b.get(base, as(token))
364 require.Equal(t, head2, formHead(t, fresh.body))
365 res = b.form(base+"/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, fresh.body)}})
366 require.Equal(t, http.StatusSeeOther, res.status, res.body)
367 assert.Equal(t, base, res.hdr.Get("Location"))
368 assert.Equal(t, head2, r.headOf("main"), "the target moved to the head")
369
370 done := b.get(base, nil)
371 assert.Contains(t, done.body, "merged")
372 assert.Contains(t, done.body, "fast-forward")
373 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
374 assert.NotContains(t, done.body, "<form")
375 assert.NotContains(t, b.get(base, as(token)).body, `id="merge-menu"`, "nothing to merge any more")
376 // Writes on a merged request are refused with a page.
377 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
378 assert.Equal(t, http.StatusConflict, late.status)
379 assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)
380 list := b.get("/proj/merge-requests", nil)
381 assert.Contains(t, list.body, "Merged and closed")
382 assert.Regexp(t, `(?s)Merged and closed.*Add search`, list.body)
383}
384
385// Squash and close, and the order of the list: open first, then the decided.
386func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
387 r := newMRRepo(t, domain.VisibilityPublic)
388 b := browser{r.harness}
389 token := r.signIn(t)
390 head := r.headOf("feat/search")
391 main0 := r.headOf("main")
392
393 // Open from compare with a description; the merge form squashes.
394 res := b.form("/proj/merge-requests", token, url.Values{
395 "source": {"feat/search"}, "target": {"main"}, "title": {"Squash me"}, "description": {""},
396 })
397 require.Equal(t, http.StatusSeeOther, res.status, res.body)
398 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
399 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
400 require.Equal(t, http.StatusSeeOther, res.status, res.body)
401 r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
402 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
403 merged := r.headOf("main")
404 assert.NotEqual(t, head, merged, "squash makes a new commit")
405 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", "main^"), "on top of the old target")
406 assert.Contains(t, r.bareGit("proj", "cat-file", "-p", "main"), "gpgsig", "signed by the instance key")
407 page := b.get("/proj/merge-requests/1", nil)
408 assert.Contains(t, page.body, "squash")
409 assert.Contains(t, page.body, `href="/proj/commit/`+merged+`"`)
410
411 // A second request: closed.
412 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/other", "main")
413 r.mustGit(r.wt, "pull", "-q", r.url, "main")
414 r.writeFile(r.wt, "other.go", []byte("package other\n"))
415 r.mustGit(r.wt, "push", "-q", r.url, "feat/other")
416 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/third", "feat/other")
417 r.writeFile(r.wt, "third.go", []byte("package third\n"))
418 r.mustGit(r.wt, "push", "-q", r.url, "feat/third")
419 for _, title := range []struct{ src, title string }{{"feat/other", "Other change"}, {"feat/third", "Third change"}} {
420 res = b.form("/proj/merge-requests", token, url.Values{"source": {title.src}, "target": {"main"}, "title": {title.title}})
421 require.Equal(t, http.StatusSeeOther, res.status, res.body)
422 }
423 res = b.form("/proj/merge-requests/2/close", token, nil)
424 require.Equal(t, http.StatusSeeOther, res.status, res.body)
425 assert.Equal(t, "/proj/merge-requests/2", res.hdr.Get("Location"))
426 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
427 closed := b.get("/proj/merge-requests/2", nil)
428 assert.Contains(t, closed.body, `data-state="closed"`)
429 assert.NotContains(t, closed.body, "<form")
430 assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/2/close", token, nil).status, "closing twice says so")
431
432 // Open one first, then merged and closed ones, newest first within each.
433 list := b.get("/proj/merge-requests", nil).body
434 third, squash, other := strings.Index(list, "Third change"), strings.Index(list, "Squash me"), strings.Index(list, "Other change")
435 require.True(t, third >= 0 && squash >= 0 && other >= 0, list)
436 assert.Less(t, third, squash, "the open request comes first")
437 assert.Less(t, third, other)
438 assert.Less(t, strings.Index(list, ">Open<"), strings.Index(list, "Merged and closed"))
439 assert.Less(t, other, squash, "newest first among the decided")
440
441 // A compare page of a branch that is behind says so.
442 r.mustGit(r.wt, "checkout", "-q", "-b", "feat/stale", main0)
443 r.writeFile(r.wt, "stale.go", []byte("package stale\n"))
444 r.mustGit(r.wt, "push", "-q", r.url, "feat/stale")
445 stale := b.get("/proj/compare/feat/stale", nil)
446 assert.Equal(t, 200, stale.status)
447 assert.Contains(t, stale.body, "has moved on")
448}
449
450// A "WIP:" request is a draft: the list and the page say so, the box offers
451// "Ready for review" instead of the merge form and the terminal lines, the
452// merge is refused; ready drops the prefix and the merge form comes back. The
453// tab counts the open requests.
454func TestWebMergeRequestDraft(t *testing.T) {
455 r := newMRRepo(t, domain.VisibilityPublic)
456 b := browser{r.harness}
457 token := r.signIn(t)
458 head := r.headOf("feat/search")
459 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "WIP: search").ok(t)
460
461 page := b.get("/proj/merge-requests/1", as(token)).body
462 assert.Contains(t, page, `data-state="draft"`)
463 assert.Contains(t, page, `action="/proj/merge-requests/1/ready"`)
464 assert.NotContains(t, page, `action="/proj/merge-requests/1/merge"`)
465 assert.NotContains(t, page, "ermite mr merge", "no terminal line for a draft")
466 assert.Contains(t, page, `<span class="count">1<span class="sr"> open</span></span>`, "the tab counts the open requests")
467 assert.Contains(t, b.get("/proj/merge-requests", nil).body, `data-state="draft"`)
468
469 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
470 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
471 assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
472 assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
473 assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
474
475 res = b.form("/proj/merge-requests/1/ready", token, nil)
476 require.Equal(t, http.StatusSeeOther, res.status, res.body)
477 page = b.get("/proj/merge-requests/1", as(token)).body
478 assert.Contains(t, page, "> search</h1>", "the prefix is gone")
479 assert.Contains(t, page, `data-state="open"`)
480 assert.Contains(t, page, `action="/proj/merge-requests/1/merge"`)
481 assert.Contains(t, page, `<details class="d-or">`, "the terminal lines fold away")
482 assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/1/ready", token, nil).status, "not a draft any more")
483}
484
485// A request opened with a token shows the owner's name, not the label of the
486// token, which names a device.
487func TestWebMergeRequestAuthorIsTheOwner(t *testing.T) {
488 r := newMRRepo(t, domain.VisibilityPublic)
489 out, errOut, code := r.bin(t, adminToken, "token", "create", "laptop", "--preset", "cli")
490 require.Equal(t, 0, code, errOut)
491 r.cli(t, strings.TrimSpace(out), "mr", "create", "proj", "--source", "feat/search", "--title", "search").ok(t)
492
493 b := browser{r.harness}
494 for _, path := range []string{"/proj/merge-requests/1", "/proj/merge-requests"} {
495 body := b.get(path, nil).body
496 assert.NotContains(t, body, "laptop", path)
497 }
498 assert.Contains(t, b.get("/proj/merge-requests", nil).body, `<td class="c-who dim">owner</td>`)
499}
500
501// The box offers no deletion for the default branch; a fast-forward with the
502// box checked deletes the source.
503func TestWebMergeDeleteBranchBox(t *testing.T) {
504 r := newMRRepo(t, domain.VisibilityPublic)
505 b := browser{r.harness}
506 token := r.signIn(t)
507 // old is an orphan branch: main can be proposed to it, and main stays an
508 // ancestor of feat/search so that it fast-forwards.
509 r.mustGit(r.wt, "checkout", "-q", "--orphan", "old")
510 r.writeFile(r.wt, "old.txt", []byte("old\n")) // commits
511 r.mustGit(r.wt, "push", "-q", r.url, "old")
512
513 for _, mr := range []url.Values{
514 {"source": {"feat/search"}, "target": {"main"}, "title": {"Search"}},
515 {"source": {"main"}, "target": {"old"}, "title": {"Back"}},
516 } {
517 require.Equal(t, http.StatusSeeOther, b.form("/proj/merge-requests", token, mr).status)
518 }
519 assert.NotContains(t, b.get("/proj/merge-requests/2", as(token)).body, `name="delete_branch"`, "never for the default branch")
520
521 one := b.get("/proj/merge-requests/1", as(token))
522 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
523 require.Equal(t, http.StatusSeeOther, res.status, res.body)
524 r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
525 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
526 done := b.get("/proj/merge-requests/1", nil)
527 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
528 assert.NotContains(t, done.body, `href="/proj/tree/feat/search"`, "no link to a gone branch")
529}
530
531// The comment limit of the API is a message, not a crash.
532func TestWebMergeRequestLimitIsFriendly(t *testing.T) {
533 r := newMRRepo(t, domain.VisibilityPublic)
534 b := browser{r.harness}
535 token := r.signIn(t)
536 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
537 for i := 0; i < 1000; i++ {
538 _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, ports.AddCommentParams{Text: "n"})
539 require.NoError(t, err)
540 }
541 full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}})
542 assert.Equal(t, http.StatusConflict, full.status)
543 assert.Contains(t, full.body, "limit reached")
544 assert.Contains(t, full.body, "1000 comments")
545 assert.Equal(t, "no-store", full.hdr.Get("Cache-Control"))
546}
547
548// Text is escaped everywhere and invisible characters are shown as markers.
549func TestWebMergeRequestEscapesText(t *testing.T) {
550 r := newMRRepo(t, domain.VisibilityPublic)
551 b := browser{r.harness}
552 token := r.signIn(t)
553 res := b.form("/proj/merge-requests", token, url.Values{
554 "source": {"feat/search"}, "target": {"main"}, "title": {`"><script>alert(1)</script>`},
555 "description": {"<img src=x onerror=alert(1)>\nhttp://x.test/\"onmouseover=\"alert(1)"},
556 })
557 require.Equal(t, http.StatusSeeOther, res.status, res.body)
558 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "<script>x</script> & more").ok(t)
559 for _, tok := range []string{"", token} {
560 var h map[string]string
561 if tok != "" {
562 h = as(tok)
563 }
564 body := b.get("/proj/merge-requests/1", h).body
565 assert.NotContains(t, body, "<script>alert")
566 assert.NotContains(t, body, "<img src=x")
567 assert.NotContains(t, body, "<script>x</script>")
568 assert.Contains(t, html.UnescapeString(body), `"><script>alert(1)</script>`)
569 // A note is safe Markdown: a line that opens with an HTML tag is an HTML
570 // block, dropped whole.
571 assert.Contains(t, body, `class="c-body prose"><!-- raw HTML omitted -->`)
572 assertNoInline(t, body)
573 assert.NotContains(t, body, `onmouseover="alert`, "an address stops at a quote")
574 }
575}
576
577// The merge request pages learn of changes over the event stream: a fixed
578// sentence and a link, never the text of a comment or a title.
579func TestWebMergeRequestEvents(t *testing.T) {
580 r := newMRRepo(t, domain.VisibilityPublic)
581 list := r.sse("topic=repo:proj&view=mrs", "")
582 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
583 ev := nextNamed(t, list, "mr-notice")
584 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
585 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
586 assert.NotContains(t, ev.data, "Secret")
587
588 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
589 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
590 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
591 ev = nextNamed(t, one, "mr-notice")
592 assert.Contains(t, ev.data, "a comment was added")
593 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
594 assert.NotContains(t, ev.data, "private words")
595
596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
598 ev = nextNamed(t, one, "mr-head")
599 assert.Equal(t, `<div id="mr-head"></div>`, ev.data)
600 assert.NotContains(t, ev.data, "the branch changed")
601
602 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
603 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
604
605 // A page of another request hears nothing of this one.
606 for drained := false; !drained; {
607 select {
608 case ev := <-other.events:
609 if !ev.comment && ev.name != "" {
610 t.Fatalf("unexpected event %+v", ev)
611 }
612 default:
613 drained = true
614 }
615 }
616 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")+`&target=main"`)
617}
618
619// nextNamed returns the next event that carries the fragment event: the
620// events that change nothing on the page (an id alone) are skipped.
621func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
622 t.Helper()
623 for range 10 {
624 if ev := s.next(t, watchWait); ev.name == name {
625 return ev
626 }
627 }
628 t.Fatalf("no %s event", name)
629 return sseEvent{}
630}
631
632func (r *mrRepo) get1(path string) string {
633 r.t.Helper()
634 _, _, body := r.get(path)
635 return body
636}
637
638// A rewritten branch that dropped the commit the owner read says so, and shows
639// the whole change.
640func TestWebMergeRequestSinceAfterForcePush(t *testing.T) {
641 r := newMRRepo(t, domain.VisibilityPublic)
642 b := browser{r.harness}
643 token := r.signIn(t)
644 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t)
645 base := "/proj/merge-requests/1"
646
647 nothing := b.get(base+"/since", as(token))
648 assert.Equal(t, 200, nothing.status)
649 assert.Contains(t, nothing.body, "nothing was marked read yet")
650
651 head1 := r.headOf("feat/search")
652 require.Equal(t, http.StatusSeeOther, b.form(base+"/read", token, url.Values{"head_id": {head1}}).status)
653 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")
654 r.mustGit(r.wt, "reset", "-q", "--hard", "main")
655 r.writeFile(r.wt, "other.txt", []byte("o\n"))
656 r.mustGit(r.wt, "push", "-q", "--force", r.url, "feat/search")
657 // Push refreshes the pull refs asynchronously. Reconcile them synchronously
658 // before GC, or the old pull head/merge can still retain the read commit.
659 require.NoError(t, r.forge.CI.RescanPulls(r.ctx, r.name("proj")))
660 require.NotContains(t, strings.Fields(r.bareGit("proj", "rev-list", "--all")), head1)
661 r.bareGit("proj", "reflog", "expire", "--expire=now", "--all")
662 r.bareGit("proj", "gc", "-q", "--prune=now")
663
664 lost := b.get(base+"/since", as(token))
665 require.Equal(t, 200, lost.status)
666 assert.Contains(t, lost.body, "is gone, the branch was rewritten")
667 assert.Contains(t, lost.body, "other.txt", "the whole change is shown")
668}
669
670// A request of many files: the page lists every file and holds the hunks of
671// the first window only; the other files are placeholders that load their
672// window from /files, which carries the base and the head of the diff. A
673// comment on a far file comes with its window; a moved head answers 409; the
674// page of a window (?from=, the links of a page without scripts) shows its
675// hunks and its line forms.
676func TestWebMergeRequestFileWindows(t *testing.T) {
677 r := newMRRepo(t, domain.VisibilityPublic)
678 b := browser{r.harness}
679 token := r.signIn(t)
680 const n = 60
681 for i := range n {
682 r.writeFile(r.wt, fmt.Sprintf("many/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
683 }
684 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
685 head := r.headOf("feat/search")
686 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Many").ok(t)
687 r.cli(t, adminToken, "mr", "comment", "proj", "1", "--path", "many/f55.txt", "--line", "1", "-m", "far note").ok(t)
688
689 page := b.get("/proj/merge-requests/1", nil)
690 require.Equal(t, 200, page.status)
691 assert.Equal(t, n+1, strings.Count(page.body, `<li><a href="#f`), "every file is listed")
692 assert.Equal(t, n+1-25, strings.Count(page.body, `class="dlazy`), "the files past the first window are placeholders")
693 assert.NotContains(t, page.body, "far note", "the comment of a far file comes with its window")
694 assert.Contains(t, page.body, `data-notes="c1"`, "its placeholder knows it")
695 assert.Contains(t, page.body, `<span class="cn" title="1 open comment">1</span>`, "the list counts it")
696 m := reWindowSrc.FindStringSubmatch(page.body)
697 require.Len(t, m, 2, "a placeholder holds the address of its window")
698 src := html.UnescapeString(m[1])
699 assert.Contains(t, src, "head="+head)
700
701 win := b.get(strings.Replace(src, "from=25", "from=50", 1), nil)
702 require.Equal(t, 200, win.status, win.body)
703 assert.Contains(t, win.body, "far note")
704 assert.Contains(t, win.body, `line 55</td>`)
705 assert.NotContains(t, win.body, "<html", "a window is a fragment")
706 assert.NotContains(t, win.body, `class="dlazy`)
707 // The window renders the layouts of the page: both when it switches them,
708 // the one it shows otherwise.
709 both := b.get(src+"&view=split&layouts=both", nil)
710 assert.Contains(t, both.body, `class="dt uni"`)
711 assert.Contains(t, both.body, `class="dt spl"`)
712 one := b.get(src+"&view=split&layouts=one", nil)
713 assert.NotContains(t, one.body, `class="dt uni"`)
714 assert.Contains(t, one.body, `class="dt spl"`)
715 assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=7", 1), nil).status, "not a window start")
716 assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=100", 1), nil).status, "past the files")
717
718 // The page of a window, without scripts: its hunks, the line forms of the
719 // owner point back to it.
720 own := b.get("/proj/merge-requests/1?from=50", as(token))
721 require.Equal(t, 200, own.status)
722 assert.Contains(t, own.body, "line 55</td>")
723 assert.Contains(t, own.body, `href="/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt#f`)
724 assert.NotContains(t, own.body, "line 0</td>", "the first window is a placeholder there")
725 assert.Contains(t, own.body, `href="/proj/merge-requests/1?view=split&from=50"`, "the layout links keep the window")
726 assert.Equal(t, 404, b.get("/proj/merge-requests/1?from=3", nil).status)
727
728 // A comment on a line of a far window: its form, its cancel link and the
729 // answer return to that window.
730 form := b.get("/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt", as(token))
731 require.Equal(t, 200, form.status)
732 assert.Contains(t, form.body, `name="from" value="50"`)
733 assert.Contains(t, form.body, `class="btn" href="/proj/merge-requests/1?from=50#f`)
734 res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"near it"}, "path": {"many/f55.txt"}, "line": {"1"}, "side": {"new"}, "from": {"50"}})
735 require.Equal(t, http.StatusSeeOther, res.status, res.body)
736 assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
737 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")
738 res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"true"}, "from": {"50"}})
739 assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location"))
740 res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"false"}, "from": {"7"}})
741 assert.Equal(t, "/proj/merge-requests/1#c2", res.hdr.Get("Location"), "a window that is none is dropped")
742
743 // The head moved: the window says to reload, never a file of another diff.
744 r.writeFile(r.wt, "later.txt", []byte("later\n"))
745 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
746 stale := b.get(src, nil)
747 assert.Equal(t, http.StatusConflict, stale.status)
748 assert.Contains(t, stale.body, "data-moved")
749}
750
751var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
752
753// advanceMain pushes a commit to main that the feature branch lacks. file is
754// the file it adds (give search.go to conflict with the feature branch).
755func (r *mrRepo) advanceMain(file, content string) {
756 r.t.Helper()
757 r.mustGit(r.wt, "checkout", "-q", "main")
758 r.writeFile(r.wt, file, []byte(content))
759 r.mustGit(r.wt, "push", "-q", r.url, "main")
760 r.mustGit(r.wt, "checkout", "-q", "feat/search")
761}
762
763// mrPageWith polls the owner's page of !1 until it contains want.
764func (r *mrRepo) mrPageWith(token, want string) string {
765 r.t.Helper()
766 var body string
767 require.Eventually(r.t, func() bool {
768 body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
769 return strings.Contains(body, want)
770 }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
771 return body
772}
773
774// The merge box shows the mergeability of the request: ready, then behind
775// (fast-forward is disabled with its reason, the other modes are not), then
776// in conflict (no mode is possible and the button says why).
777func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
778 r := newMRRepo(t, domain.VisibilityPublic)
779 token := r.signIn(t)
780 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
781
782 body := r.mrPageWith(token, "Ready to merge")
783 assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
784 assert.Contains(t, body, `data-state="up_to_date"`)
785 assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
786 assert.NotContains(t, body, `value="fast-forward" checked disabled`)
787 assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
788 assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
789 assert.Contains(t, body, "<code>ermite signing-key show</code>")
790 assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
791 assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
792 assertNoInline(t, body)
793
794 // Main moves on: the branch is behind by one commit and merges cleanly.
795 r.advanceMain("other.txt", "other\n")
796 body = r.mrPageWith(token, "Behind main by 1 commit")
797 assert.Contains(t, body, "merges without conflicts")
798 assert.Contains(t, body, `data-ff-ok="false"`)
799 assert.Contains(t, body, `data-merge-ok="true"`)
800 assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
801 assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
802 assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
803 assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
804
805 // A conflicting change to main: every mode is disabled and the files are listed.
806 r.mustGit(r.wt, "checkout", "-q", "main")
807 r.writeFile(r.wt, "search.go", []byte("package other\n"))
808 r.mustGit(r.wt, "push", "-q", r.url, "main")
809 body = r.mrPageWith(token, "Conflicts with main")
810 assert.Contains(t, body, "rebase needed")
811 assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
812 assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
813 assert.NotContains(t, body, `name="mode" value="squash" checked`)
814
815 // The server refuses it too, and says so in the box of the page.
816 res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
817 assert.Equal(t, http.StatusConflict, res.status)
818 assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
819 assert.Contains(t, res.body, "conflicts with main")
820}
821
822// A merge the server refuses (the target moved after the page was read) shows
823// the page of the request again, 409, with the reason in its merge box.
824func TestWebMergeRequestInlineMergeError(t *testing.T) {
825 r := newMRRepo(t, domain.VisibilityPublic)
826 b := browser{r.harness}
827 token := r.signIn(t)
828 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
829 body := r.mrPageWith(token, "Ready to merge")
830 head := formHead(t, body)
831
832 r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
833 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
834 assert.Equal(t, http.StatusConflict, res.status)
835 assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
836 assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
837 assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
838 assert.Contains(t, res.body, "the target has moved")
839 assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
840 assert.Equal(t, head, r.headOf("feat/search"))
841 assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
842
843 // A head that moved is refused the same way.
844 r.writeFile(r.wt, "more.txt", []byte("more\n"))
845 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
846 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
847 assert.Equal(t, http.StatusConflict, res.status)
848 assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
849 assert.Contains(t, res.body, "branch moved")
850
851 // Other refusals keep the state page; a mode that does not exist is a 400.
852 assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
853}
854
855// A merge commit through the page; with the branch deletion requested the
856// merged page says the branch is being deleted, not that it is gone.
857func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
858 r := newMRRepo(t, domain.VisibilityPublic)
859 b := browser{r.harness}
860 token := r.signIn(t)
861 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
862 r.advanceMain("other.txt", "other\n")
863 body := r.mrPageWith(token, "Behind main by 1 commit")
864 head := formHead(t, body)
865 main0 := r.headOf("main")
866
867 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
868 require.Equal(t, http.StatusSeeOther, res.status, res.body)
869 assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
870 merged := r.headOf("main")
871 assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
872 assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
873 assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
874 page := b.get("/proj/merge-requests/1?deleting=1", as(token))
875 assert.Contains(t, page.body, "merge commit")
876 if strings.Contains(page.body, "is being deleted") {
877 assert.Contains(t, page.body, "feat/search is being deleted")
878 }
879 assert.NotContains(t, page.body, "is deleted")
880 r.forge.MergeRequests.Wait(r.ctx)
881 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
882 assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
883}
884
885// The badge follows the request live: a push to the target turns the fragment
886// of the page to "checking" or "behind", and the settled result follows.
887func TestWebMergeRequestMergeabilityLive(t *testing.T) {
888 r := newMRRepo(t, domain.VisibilityPublic)
889 token := r.signIn(t)
890 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
891 r.mrPageWith(token, "Ready to merge")
892
893 one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
894 anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
895 r.advanceMain("other.txt", "other\n")
896 deadline := time.After(watchWait)
897 for done := false; !done; {
898 ev := nextNamed(t, one, "mr-mergeability")
899 select {
900 case <-deadline:
901 t.Fatal("the badge never showed the result")
902 default:
903 }
904 if strings.Contains(ev.data, "Behind main by 1 commit") {
905 assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
906 assert.Contains(t, ev.data, `data-ff-ok="false"`)
907 assert.Contains(t, ev.data, `data-merge-ok="true"`)
908 done = true
909 } else {
910 assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
911 }
912 }
913 // The box is the owner's: a visitor's stream carries no badge.
914 for {
915 select {
916 case ev := <-anon.events:
917 assert.NotEqual(t, "mr-mergeability", ev.name)
918 continue
919 default:
920 }
921 break
922 }
923}
924
925// Collapsed files of a merge request: a generated file and a file over the
926// line cap are listed closed, with no rows and a manual placeholder; /files
927// with one=1 reads exactly one file in full, whatever its index; the page
928// without scripts (?file=i) splices the file in.
929func TestWebMergeRequestCollapsedFiles(t *testing.T) {
930 r := newMRRepo(t, domain.VisibilityPublic)
931 b := browser{r.harness}
932 var big strings.Builder
933 for i := range 2600 {
934 fmt.Fprintf(&big, "row %d\n", i)
935 }
936 r.writeFile(r.wt, "zz/big.txt", []byte(big.String()))
937 r.writeFile(r.wt, "vendor/lib/gen.txt", []byte("generated line\n"))
938 r.writeFile(r.wt, "zz/small.txt", []byte("small line\n"))
939 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
940 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Collapsed").ok(t)
941
942 page := b.get("/proj/merge-requests/1", nil)
943 require.Equal(t, 200, page.status)
944 assert.Equal(t, 2, strings.Count(page.body, "data-manual"), "the large and the generated file")
945 assert.Contains(t, page.body, "generated file: collapsed")
946 assert.Contains(t, page.body, "large diff (2600 lines changed): collapsed")
947 assert.NotRegexp(t, `row \d+`, page.body, "no rows for a collapsed file")
948 assert.NotContains(t, page.body, "generated line</td>")
949 assert.Contains(t, page.body, "small line")
950 assert.NotContains(t, page.body, "this diff is incomplete")
951 srcs := regexp.MustCompile(`data-src="([^"]*one=1&from=(\d+))" data-manual`).FindAllStringSubmatch(page.body, -1)
952 require.Len(t, srcs, 2)
953
954 for _, m := range srcs {
955 one := b.get(html.UnescapeString(m[1]), nil)
956 require.Equal(t, 200, one.status, one.body)
957 assert.Equal(t, 1, strings.Count(one.body, `<details class="dfile`), "exactly one file")
958 assert.NotContains(t, one.body, "data-manual")
959 }
960 bigOne := b.get(html.UnescapeString(srcs[1][1]), nil)
961 genOne := b.get(html.UnescapeString(srcs[0][1]), nil)
962 assert.True(t, strings.Contains(bigOne.body, "row 2599") != strings.Contains(genOne.body, "row 2599"))
963 assert.Contains(t, bigOne.body+genOne.body, "generated line")
964
965 assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=999", 1), nil).status)
966 assert.Equal(t, 404, b.get(strings.Replace(html.UnescapeString(srcs[0][1]), "from="+srcs[0][2], "from=-1", 1), nil).status)
967
968 for _, m := range srcs {
969 own := b.get("/proj/merge-requests/1?file="+m[2], nil)
970 require.Equal(t, 200, own.status)
971 assert.Equal(t, 1, strings.Count(own.body, "data-manual"), "that one file is spliced in")
972 }
973}
974
975// The page of a window without scripts: ?file=i splices that collapsed file in
976// and the load link of a window keeps the window.
977func TestWebMergeRequestWindowFileSplice(t *testing.T) {
978 r := newMRRepo(t, domain.VisibilityPublic)
979 b := browser{r.harness}
980 for i := range 30 {
981 r.writeFile(r.wt, fmt.Sprintf("a/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i)))
982 }
983 r.writeFile(r.wt, "vendor/gen.txt", []byte("generated line\n"))
984 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
985 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Window").ok(t)
986
987 // Files sort as a/f00..a/f29, search.go, vendor/gen.txt: the generated
988 // file is index 31, in the window that starts at 25.
989 win := b.get("/proj/merge-requests/1?from=25", nil)
990 require.Equal(t, 200, win.status)
991 m := regexp.MustCompile(`href="(\?from=25&file=(\d+)#f\d+)"`).FindStringSubmatch(win.body)
992 require.Len(t, m, 3, "the load link keeps the window")
993 assert.Equal(t, "31", m[2])
994 assert.NotContains(t, win.body, "generated line</td>")
995
996 own := b.get("/proj/merge-requests/1?from=25&file=31", nil)
997 require.Equal(t, 200, own.status)
998 assert.Contains(t, own.body, "generated line</td>", "the file is spliced in")
999 assert.NotContains(t, own.body, "data-manual")
1000}