patches_test.go
⎇
Raw
1package e2e
2
3import (
4 "net/http"
5 "net/url"
6 "os"
7 "strings"
8 "testing"
9
10 "github.com/PuerkitoBio/goquery"
11)
12
13// patchFile is the upload part every patch form expects.
14func patchFile(content string) file {
15 return file{Field: "patch_file", Name: "test.patch", Content: []byte(content)}
16}
17
18// patchCreate uploads a new patch and returns the response of the form POST.
19func patchCreate(s *session, repo, title, description, content string) *response {
20 return s.postMultipart("/"+repo+"/patches",
21 url.Values{"title": {title}, "description": {description}},
22 patchFile(content))
23}
24
25// patchFilterText returns the elements matching sel whose text contains sub.
26func patchFilterText(r *response, sel, sub string) *goquery.Selection {
27 return r.Find(sel).FilterFunction(func(_ int, s *goquery.Selection) bool {
28 return strings.Contains(s.Text(), sub)
29 })
30}
31
32// patchGitLog reads one formatted field of the newest commit.
33func patchGitLog(t *testing.T, repoDir, format string) string {
34 t.Helper()
35 return gitRun(t, repoDir, "log", "-1", "--format="+format)
36}
37
38// Adds a new file — applies cleanly to my-repo.
39const cleanPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001
40From: Test User <test@example.com>
41Date: Mon, 01 Jan 2024 12:00:00 +0000
42Subject: [PATCH] Add patch-test.txt
43
44---
45diff --git a/patch-test.txt b/patch-test.txt
46new file mode 100644
47index 0000000..9daeafb
48--- /dev/null
49+++ b/patch-test.txt
50@@ -0,0 +1 @@
51+patch test content
52`
53
54// References non-existent lines in README.md — always conflicts.
55const conflictPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001
56From: Test User <test@example.com>
57Date: Mon, 01 Jan 2024 12:00:00 +0000
58Subject: [PATCH] Modify README
59
60---
61diff --git a/README.md b/README.md
62index abc1234..def5678 100644
63--- a/README.md
64+++ b/README.md
65@@ -50,3 +50,3 @@
66 nonexistent context line
67-nonexistent old line
68+nonexistent new line
69`
70
71// Adds another new file — for testing close flow.
72const closePatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001
73From: Test User <test@example.com>
74Date: Mon, 01 Jan 2024 12:00:00 +0000
75Subject: [PATCH] Add patch-close.txt
76
77---
78diff --git a/patch-close.txt b/patch-close.txt
79new file mode 100644
80index 0000000..9daeafb
81--- /dev/null
82+++ b/patch-close.txt
83@@ -0,0 +1 @@
84+close test
85`
86
87// Adds upload-test.txt — applies cleanly to my-repo.
88const uploadTestPatch = `From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001
89From: Original Author <original@example.com>
90Date: Wed, 03 Jan 2024 10:00:00 +0000
91Subject: [PATCH] Add upload-test.txt
92
93---
94diff --git a/upload-test.txt b/upload-test.txt
95new file mode 100644
96index 0000000..9daeafb
97--- /dev/null
98+++ b/upload-test.txt
99@@ -0,0 +1 @@
100+upload test
101`
102
103// Replacement: different author, same diff target.
104const replacementPatch = `From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001
105From: Replaced Author <replaced@example.com>
106Date: Thu, 04 Jan 2024 10:00:00 +0000
107Subject: [PATCH] Add upload-test.txt (v2)
108
109---
110diff --git a/upload-test.txt b/upload-test.txt
111new file mode 100644
112index 0000000..9daeafb
113--- /dev/null
114+++ b/upload-test.txt
115@@ -0,0 +1 @@
116+upload test v2
117`
118
119func TestPatches(t *testing.T) {
120 e := newEnv(t)
121 e.register("alice", "password123")
122 admin := e.admin()
123 e.createRepo(admin, "my-repo")
124 e.seedRepo("my-repo", nil)
125
126 var cleanURL, conflictURL, closeURL, uploadTestURL string
127
128 t.Run("reject file without patch markers", func(t *testing.T) {
129 r := admin.postMultipart("/my-repo/patches", url.Values{"title": {"Bad patch"}},
130 file{Field: "patch_file", Name: "not-a-patch.txt", Content: []byte("this is just plain text")})
131 if !strings.Contains(r.Text(".form-error"), "valid patch") {
132 t.Errorf("error = %q", r.Text(".form-error"))
133 }
134 })
135
136 t.Run("reject patch missing Subject header", func(t *testing.T) {
137 r := patchCreate(admin, "my-repo", "No subject", "", `From: Test User <test@example.com>
138Date: Mon, 01 Jan 2024 12:00:00 +0000
139
140---
141diff --git a/f.txt b/f.txt
142new file mode 100644
143--- /dev/null
144+++ b/f.txt
145@@ -0,0 +1 @@
146+x
147`)
148 if !strings.Contains(r.Text(".form-error"), "Subject") {
149 t.Errorf("error = %q", r.Text(".form-error"))
150 }
151 })
152
153 t.Run("reject patch missing From header", func(t *testing.T) {
154 r := patchCreate(admin, "my-repo", "No from", "", `Date: Mon, 01 Jan 2024 12:00:00 +0000
155Subject: [PATCH] Add f.txt
156
157---
158diff --git a/f.txt b/f.txt
159new file mode 100644
160--- /dev/null
161+++ b/f.txt
162@@ -0,0 +1 @@
163+x
164`)
165 if !strings.Contains(r.Text(".form-error"), "From") {
166 t.Errorf("error = %q", r.Text(".form-error"))
167 }
168 })
169
170 t.Run("reject patch missing Date header", func(t *testing.T) {
171 r := patchCreate(admin, "my-repo", "No date", "", `From: Test User <test@example.com>
172Subject: [PATCH] Add f.txt
173
174---
175diff --git a/f.txt b/f.txt
176new file mode 100644
177--- /dev/null
178+++ b/f.txt
179@@ -0,0 +1 @@
180+x
181`)
182 if !strings.Contains(r.Text(".form-error"), "Date") {
183 t.Errorf("error = %q", r.Text(".form-error"))
184 }
185 })
186
187 t.Run("upload clean patch", func(t *testing.T) {
188 r := patchCreate(admin, "my-repo", "Add patch-test.txt",
189 "Adds a file with **markdown** desc.", cleanPatch)
190 cleanURL = r.mustRedirect("/my-repo/patches/")
191 if got := admin.get(cleanURL).Text(".issue-detail-title"); got != "Add patch-test.txt" {
192 t.Errorf("title = %q", got)
193 }
194 })
195
196 t.Run("changes tab shows commit metadata card", func(t *testing.T) {
197 r := admin.get(cleanURL + "?tab=changes")
198 if !r.Has(".commit-card") {
199 t.Fatal("commit-card missing")
200 }
201 if !strings.Contains(r.Text(".commit-card-subject"), "Add patch-test.txt") {
202 t.Errorf("subject = %q", r.Text(".commit-card-subject"))
203 }
204 meta := r.Text(".commit-card-meta")
205 if !strings.Contains(meta, "Test User") || !strings.Contains(meta, "test@example.com") {
206 t.Errorf("meta = %q", meta)
207 }
208 if !r.Has(".commit-card-meta time") {
209 t.Error("commit-card-meta time missing")
210 }
211 })
212
213 t.Run("patch description renders markdown", func(t *testing.T) {
214 html, err := admin.get(cleanURL).Find(".timeline-body.markdown-body").First().Html()
215 if err != nil {
216 t.Fatal(err)
217 }
218 if !strings.Contains(html, "<strong>") {
219 t.Errorf("description html = %q", html)
220 }
221 })
222
223 t.Run("clean patch shows apply-clean status immediately", func(t *testing.T) {
224 r := admin.get(cleanURL)
225 if !r.Has(".apply-result") || !r.Has(".apply-clean") {
226 t.Error("apply-clean status missing")
227 }
228 })
229
230 t.Run("merge button appears for clean patch", func(t *testing.T) {
231 if !admin.get(cleanURL).Has(`form[action*="/merge"] button`) {
232 t.Error("merge button missing")
233 }
234 })
235
236 t.Run("patch diff is displayed with highlighted table", func(t *testing.T) {
237 r := admin.get(cleanURL + "?tab=changes")
238 if !r.Has(".diff-table") {
239 t.Error("diff-table missing")
240 }
241 if r.Count(".diff-row-add") == 0 {
242 t.Error("no added diff rows")
243 }
244 })
245
246 t.Run("patch appears in open list", func(t *testing.T) {
247 if !contains(admin.get("/my-repo/patches").Texts(".issue-title"), "Add patch-test.txt") {
248 t.Error("patch not in open list")
249 }
250 })
251
252 t.Run("unauthenticated user is redirected to login from patch upload", func(t *testing.T) {
253 e.anon().get("/my-repo/patches/new").mustRedirect("/login")
254 })
255
256 t.Run("upload conflict patch", func(t *testing.T) {
257 conflictURL = patchCreate(admin, "my-repo", "Conflict patch", "", conflictPatch).
258 mustRedirect("/my-repo/patches/")
259 })
260
261 t.Run("conflict patch shows apply-conflict status", func(t *testing.T) {
262 if !admin.get(conflictURL).Has(".apply-conflict") {
263 t.Error("apply-conflict missing")
264 }
265 })
266
267 t.Run("merge button absent for conflict patch", func(t *testing.T) {
268 if n := admin.get(conflictURL).Count(`form[action*="/merge"] button`); n != 0 {
269 t.Errorf("merge buttons = %d", n)
270 }
271 })
272
273 t.Run("merge clean patch changes status to merged", func(t *testing.T) {
274 version := admin.get(cleanURL).Value(`form[action*="/merge"] input[name=version]`)
275 admin.post(cleanURL+"/merge", url.Values{"version": {version}}).mustRedirect(cleanURL)
276 if got := admin.get(cleanURL).Text(".patch-badge"); got != "merged" {
277 t.Errorf("badge = %q", got)
278 }
279 })
280
281 t.Run("merge uses patch From header as git author", func(t *testing.T) {
282 dir := e.repoPath("my-repo")
283 if got := patchGitLog(t, dir, "%aN"); got != "Test User" {
284 t.Errorf("author name = %q", got)
285 }
286 if got := patchGitLog(t, dir, "%aE"); got != "test@example.com" {
287 t.Errorf("author email = %q", got)
288 }
289 if got := patchGitLog(t, dir, "%s"); got != "Add patch-test.txt" {
290 t.Errorf("subject = %q", got)
291 }
292 })
293
294 t.Run("merged patch appears in merged list", func(t *testing.T) {
295 if !contains(admin.get("/my-repo/patches?status=merged").Texts(".issue-title"), "Add patch-test.txt") {
296 t.Error("patch not in merged list")
297 }
298 })
299
300 t.Run("upload and close a patch", func(t *testing.T) {
301 closeURL = patchCreate(admin, "my-repo", "Close me", "", closePatch).
302 mustRedirect("/my-repo/patches/")
303 admin.post(closeURL+"/close", nil).mustRedirect(closeURL)
304 if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" {
305 t.Errorf("badge = %q", got)
306 }
307 })
308
309 t.Run("closed patch appears in closed list", func(t *testing.T) {
310 if !contains(admin.get("/my-repo/patches?status=closed").Texts(".issue-title"), "Close me") {
311 t.Error("patch not in closed list")
312 }
313 })
314
315 t.Run("closed patch can be reopened", func(t *testing.T) {
316 if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" {
317 t.Fatalf("badge = %q", got)
318 }
319 admin.post(closeURL+"/close", nil).mustRedirect(closeURL)
320 if got := admin.get(closeURL).Text(".patch-badge"); got != "open" {
321 t.Errorf("badge = %q", got)
322 }
323 })
324
325 t.Run("patch title and description can be edited", func(t *testing.T) {
326 // The title form resubmits the unchanged description.
327 desc := admin.get(conflictURL).Value(`.title-edit-form [name=edit_description]`)
328 admin.post(conflictURL+"/edit", url.Values{
329 "title": {"Edited Conflict Patch"}, "edit_description": {desc},
330 }).mustRedirect(conflictURL)
331 if got := admin.get(conflictURL).Text(".issue-detail-title"); got != "Edited Conflict Patch" {
332 t.Errorf("title = %q", got)
333 }
334 // The inline description form resubmits the unchanged title.
335 title := admin.get(conflictURL).Value(`.inline-edit-form [name=title]`)
336 admin.post(conflictURL+"/edit", url.Values{
337 "title": {title}, "edit_description": {"Updated desc"},
338 }).mustRedirect(conflictURL)
339 if !strings.Contains(admin.get(conflictURL).Text(".timeline-body"), "Updated desc") {
340 t.Error("description not updated")
341 }
342 })
343
344 t.Run("patch comment: add and edit", func(t *testing.T) {
345 admin.post(conflictURL+"/comments", url.Values{"body": {"My patch comment"}}).
346 mustRedirect(conflictURL)
347 r := admin.get(conflictURL)
348 bodies := r.Texts(".timeline-body")
349 if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "My patch comment") {
350 t.Fatalf("comment bodies = %q", bodies)
351 }
352 // Edit the comment through its own edit form.
353 action, ok := patchFilterText(r, ".timeline-item", "My patch comment").
354 Find(".inline-edit-form").First().Attr("action")
355 if !ok {
356 t.Fatal("comment edit form missing")
357 }
358 admin.post(action, url.Values{"edit_body": {"Edited patch comment"}}).mustRedirect(conflictURL)
359 bodies = admin.get(conflictURL).Texts(".timeline-body")
360 if !strings.Contains(bodies[len(bodies)-1], "Edited patch comment") {
361 t.Errorf("comment bodies = %q", bodies)
362 }
363 })
364
365 t.Run("patch reaction on description", func(t *testing.T) {
366 // The picker of the first timeline item reacts on the description.
367 emoji := admin.get(conflictURL).
368 Find(".timeline-item").First().
369 Find(".reaction-picker-dropdown input[name=emoji]").First().AttrOr("value", "")
370 if emoji == "" {
371 t.Fatal("reaction picker empty")
372 }
373 admin.post(conflictURL+"/react", url.Values{"emoji": {emoji}}).mustRedirect(conflictURL)
374 if got := admin.get(conflictURL).Text(".reaction-btn"); !strings.ContainsAny(got, "0123456789") {
375 t.Errorf("reaction button = %q", got)
376 }
377 })
378
379 t.Run("admin can delete a patch", func(t *testing.T) {
380 r := admin.post(conflictURL+"/delete", nil)
381 r.mustStatus(http.StatusFound)
382 if !strings.Contains(r.Location(), "/patches") {
383 t.Errorf("location = %q", r.Location())
384 }
385 admin.get(conflictURL).mustStatus(http.StatusNotFound)
386 })
387
388 // ── Patch file re-upload & version protection ──────────────────────────
389
390 t.Run("create patch for re-upload tests", func(t *testing.T) {
391 uploadTestURL = patchCreate(admin, "my-repo", "Upload test patch", "", uploadTestPatch).
392 mustRedirect("/my-repo/patches/")
393 })
394
395 t.Run("upload patch file button is visible for admin on open patch", func(t *testing.T) {
396 if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 1 {
397 t.Errorf("upload details = %d", n)
398 }
399 })
400
401 t.Run("non-author non-admin cannot upload patch file", func(t *testing.T) {
402 alice := e.login("alice", "password123")
403 alice.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)).
404 mustStatus(http.StatusForbidden)
405 })
406
407 t.Run("upload button hidden for non-author non-admin", func(t *testing.T) {
408 alice := e.login("alice", "password123")
409 if n := alice.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 {
410 t.Errorf("upload details = %d", n)
411 }
412 })
413
414 t.Run("admin can upload replacement patch file", func(t *testing.T) {
415 admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(replacementPatch)).
416 mustRedirect(uploadTestURL)
417 })
418
419 t.Run("merge fails when version token is stale", func(t *testing.T) {
420 stalePatch := `From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001
421From: Test User <test@example.com>
422Date: Fri, 05 Jan 2024 10:00:00 +0000
423Subject: [PATCH] Add stale-version.txt
424
425---
426diff --git a/stale-version.txt b/stale-version.txt
427new file mode 100644
428index 0000000..9daeafb
429--- /dev/null
430+++ b/stale-version.txt
431@@ -0,0 +1 @@
432+stale
433`
434 stalePatchV2 := strings.ReplaceAll(
435 strings.ReplaceAll(stalePatch, "Add stale-version.txt", "Add stale-version.txt (v2)"),
436 "+stale", "+stale v2",
437 )
438
439 staleURL := patchCreate(admin, "my-repo", "Stale version test", "", stalePatch).
440 mustRedirect("/my-repo/patches/")
441
442 // The version the admin sees on the page.
443 staleVersion := admin.get(staleURL).Value(`form[action*="/merge"] input[name=version]`)
444
445 // The author uploads a new patch file, bumping the version.
446 admin.postMultipart(staleURL+"/upload", nil, patchFile(stalePatchV2)).mustRedirect(staleURL)
447
448 r := admin.post(staleURL+"/merge", url.Values{"version": {staleVersion}})
449 r.mustStatus(http.StatusConflict)
450 if !r.Contains("updated") {
451 t.Errorf("body = %q", r.BodyString())
452 }
453 check := admin.get(staleURL).mustStatus(http.StatusOK)
454 if !check.Contains("open") {
455 t.Error("patch is no longer open")
456 }
457 })
458
459 t.Run("merge succeeds with current version token after replacement upload", func(t *testing.T) {
460 version := admin.get(uploadTestURL).Value(`form[action*="/merge"] input[name=version]`)
461 admin.post(uploadTestURL+"/merge", url.Values{"version": {version}}).mustRedirect(uploadTestURL)
462 if got := admin.get(uploadTestURL).Text(".patch-badge"); got != "merged" {
463 t.Errorf("badge = %q", got)
464 }
465 if got := patchGitLog(t, e.repoPath("my-repo"), "%aN"); got != "Replaced Author" {
466 t.Errorf("author name = %q", got)
467 }
468 })
469
470 t.Run("upload patch file button hidden on merged patch", func(t *testing.T) {
471 if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 {
472 t.Errorf("upload details = %d", n)
473 }
474 })
475
476 t.Run("POST to upload on merged patch returns 400", func(t *testing.T) {
477 admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)).
478 mustStatus(http.StatusBadRequest)
479 })
480}
481
482// TestCommitSigning checks the signature of a patch merged by the server.
483// It repeats the merge the patches suite did, because each test gets a fresh
484// server and data directory.
485func TestCommitSigning(t *testing.T) {
486 e := newEnv(t)
487 admin := e.admin()
488 e.createRepo(admin, "my-repo")
489 e.seedRepo("my-repo", nil)
490 patchURL := patchCreate(admin, "my-repo", "Add patch-test.txt", "", cleanPatch).
491 mustRedirect("/my-repo/patches/")
492 version := admin.get(patchURL).Value(`form[action*="/merge"] input[name=version]`)
493 admin.post(patchURL+"/merge", url.Values{"version": {version}}).mustRedirect(patchURL)
494
495 repoDir := e.repoPath("my-repo")
496 hashOf := func(grep string) string {
497 t.Helper()
498 h := gitRun(t, repoDir, "log", "--format=%H", "--grep="+grep, "-1")
499 if h == "" {
500 t.Fatalf("no commit matching %q", grep)
501 }
502 return h
503 }
504
505 t.Run("allowed_signers file is generated at startup", func(t *testing.T) {
506 content, err := os.ReadFile(e.Cfg.AllowedSignersPath())
507 if err != nil {
508 t.Fatal(err)
509 }
510 if !strings.Contains(string(content), `namespaces="git"`) ||
511 !strings.Contains(string(content), "ssh-ed25519") {
512 t.Errorf("allowed_signers = %q", content)
513 }
514 })
515
516 t.Run("merged commit has a gpgsig header", func(t *testing.T) {
517 obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Add patch-test.txt"))
518 if !strings.Contains(obj, "gpgsig") {
519 t.Error("gpgsig header missing")
520 }
521 })
522
523 t.Run("unsigned commits have no gpgsig header", func(t *testing.T) {
524 obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Initial commit"))
525 if strings.Contains(obj, "gpgsig") {
526 t.Error("gpgsig header present on unsigned commit")
527 }
528 })
529
530 t.Run("commit log shows verified badge on signed commit", func(t *testing.T) {
531 r := admin.get("/my-repo/commits/main")
532 item := patchFilterText(r, ".commit-item", "Add patch-test.txt")
533 if item.Find(".sig-badge.verified").Length() == 0 {
534 t.Error("verified badge missing")
535 }
536 })
537
538 t.Run("commit log shows no sig badge on unsigned commit", func(t *testing.T) {
539 r := admin.get("/my-repo/commits/main")
540 item := patchFilterText(r, ".commit-item", "Initial commit")
541 if n := item.Find(".sig-badge").Length(); n != 0 {
542 t.Errorf("sig badges = %d", n)
543 }
544 })
545
546 t.Run("commit detail shows verified signature row for signed commit", func(t *testing.T) {
547 r := admin.get("/my-repo/commit/" + hashOf("Add patch-test.txt"))
548 row := patchFilterText(r, ".commit-card-meta-row", "Signature")
549 if row.Length() == 0 {
550 t.Fatal("signature row missing")
551 }
552 if row.Find(".sig-badge.verified").Length() == 0 {
553 t.Error("verified badge missing")
554 }
555 })
556
557 t.Run("commit detail shows no signature row for unsigned commit", func(t *testing.T) {
558 r := admin.get("/my-repo/commit/" + hashOf("Initial commit"))
559 if n := patchFilterText(r, ".commit-card-meta-row", "Signature").Length(); n != 0 {
560 t.Errorf("signature rows = %d", n)
561 }
562 })
563}
564