v0.18.0 0cea275

web_mr_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close