package e2e import ( "net/http" "net/url" "os" "strings" "testing" "github.com/PuerkitoBio/goquery" ) // patchFile is the upload part every patch form expects. func patchFile(content string) file { return file{Field: "patch_file", Name: "test.patch", Content: []byte(content)} } // patchCreate uploads a new patch and returns the response of the form POST. func patchCreate(s *session, repo, title, description, content string) *response { return s.postMultipart("/"+repo+"/patches", url.Values{"title": {title}, "description": {description}}, patchFile(content)) } // patchFilterText returns the elements matching sel whose text contains sub. func patchFilterText(r *response, sel, sub string) *goquery.Selection { return r.Find(sel).FilterFunction(func(_ int, s *goquery.Selection) bool { return strings.Contains(s.Text(), sub) }) } // patchGitLog reads one formatted field of the newest commit. func patchGitLog(t *testing.T, repoDir, format string) string { t.Helper() return gitRun(t, repoDir, "log", "-1", "--format="+format) } // Adds a new file — applies cleanly to my-repo. const cleanPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 01 Jan 2024 12:00:00 +0000 Subject: [PATCH] Add patch-test.txt --- diff --git a/patch-test.txt b/patch-test.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/patch-test.txt @@ -0,0 +1 @@ +patch test content ` // References non-existent lines in README.md — always conflicts. const conflictPatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 01 Jan 2024 12:00:00 +0000 Subject: [PATCH] Modify README --- diff --git a/README.md b/README.md index abc1234..def5678 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,3 @@ nonexistent context line -nonexistent old line +nonexistent new line ` // Adds another new file — for testing close flow. const closePatch = `From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 01 Jan 2024 12:00:00 +0000 Subject: [PATCH] Add patch-close.txt --- diff --git a/patch-close.txt b/patch-close.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/patch-close.txt @@ -0,0 +1 @@ +close test ` // Adds upload-test.txt — applies cleanly to my-repo. const uploadTestPatch = `From c1d2e3f4a5b6c1d2e3f4a5b6c1d2e3f4a5b6c1d2 Mon Sep 17 00:00:00 2001 From: Original Author Date: Wed, 03 Jan 2024 10:00:00 +0000 Subject: [PATCH] Add upload-test.txt --- diff --git a/upload-test.txt b/upload-test.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/upload-test.txt @@ -0,0 +1 @@ +upload test ` // Replacement: different author, same diff target. const replacementPatch = `From d1e2f3a4b5c6d1e2f3a4b5c6d1e2f3a4b5c6d1e2 Mon Sep 17 00:00:00 2001 From: Replaced Author Date: Thu, 04 Jan 2024 10:00:00 +0000 Subject: [PATCH] Add upload-test.txt (v2) --- diff --git a/upload-test.txt b/upload-test.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/upload-test.txt @@ -0,0 +1 @@ +upload test v2 ` func TestPatches(t *testing.T) { e := newEnv(t) e.register("alice", "password123") admin := e.admin() e.createRepo(admin, "my-repo") e.seedRepo("my-repo", nil) var cleanURL, conflictURL, closeURL, uploadTestURL string t.Run("reject file without patch markers", func(t *testing.T) { r := admin.postMultipart("/my-repo/patches", url.Values{"title": {"Bad patch"}}, file{Field: "patch_file", Name: "not-a-patch.txt", Content: []byte("this is just plain text")}) if !strings.Contains(r.Text(".form-error"), "valid patch") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("reject patch missing Subject header", func(t *testing.T) { r := patchCreate(admin, "my-repo", "No subject", "", `From: Test User Date: Mon, 01 Jan 2024 12:00:00 +0000 --- diff --git a/f.txt b/f.txt new file mode 100644 --- /dev/null +++ b/f.txt @@ -0,0 +1 @@ +x `) if !strings.Contains(r.Text(".form-error"), "Subject") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("reject patch missing From header", func(t *testing.T) { r := patchCreate(admin, "my-repo", "No from", "", `Date: Mon, 01 Jan 2024 12:00:00 +0000 Subject: [PATCH] Add f.txt --- diff --git a/f.txt b/f.txt new file mode 100644 --- /dev/null +++ b/f.txt @@ -0,0 +1 @@ +x `) if !strings.Contains(r.Text(".form-error"), "From") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("reject patch missing Date header", func(t *testing.T) { r := patchCreate(admin, "my-repo", "No date", "", `From: Test User Subject: [PATCH] Add f.txt --- diff --git a/f.txt b/f.txt new file mode 100644 --- /dev/null +++ b/f.txt @@ -0,0 +1 @@ +x `) if !strings.Contains(r.Text(".form-error"), "Date") { t.Errorf("error = %q", r.Text(".form-error")) } }) t.Run("upload clean patch", func(t *testing.T) { r := patchCreate(admin, "my-repo", "Add patch-test.txt", "Adds a file with **markdown** desc.", cleanPatch) cleanURL = r.mustRedirect("/my-repo/patches/") if got := admin.get(cleanURL).Text(".issue-detail-title"); got != "Add patch-test.txt" { t.Errorf("title = %q", got) } }) t.Run("changes tab shows commit metadata card", func(t *testing.T) { r := admin.get(cleanURL + "?tab=changes") if !r.Has(".commit-card") { t.Fatal("commit-card missing") } if !strings.Contains(r.Text(".commit-card-subject"), "Add patch-test.txt") { t.Errorf("subject = %q", r.Text(".commit-card-subject")) } meta := r.Text(".commit-card-meta") if !strings.Contains(meta, "Test User") || !strings.Contains(meta, "test@example.com") { t.Errorf("meta = %q", meta) } if !r.Has(".commit-card-meta time") { t.Error("commit-card-meta time missing") } }) t.Run("patch description renders markdown", func(t *testing.T) { html, err := admin.get(cleanURL).Find(".timeline-body.markdown-body").First().Html() if err != nil { t.Fatal(err) } if !strings.Contains(html, "") { t.Errorf("description html = %q", html) } }) t.Run("clean patch shows apply-clean status immediately", func(t *testing.T) { r := admin.get(cleanURL) if !r.Has(".apply-result") || !r.Has(".apply-clean") { t.Error("apply-clean status missing") } }) t.Run("merge button appears for clean patch", func(t *testing.T) { if !admin.get(cleanURL).Has(`form[action*="/merge"] button`) { t.Error("merge button missing") } }) t.Run("patch diff is displayed with highlighted table", func(t *testing.T) { r := admin.get(cleanURL + "?tab=changes") if !r.Has(".diff-rows") { t.Error("diff-rows missing") } if r.Count(".diff-row-add") == 0 { t.Error("no added diff rows") } }) t.Run("patch appears in open list", func(t *testing.T) { if !contains(admin.get("/my-repo/patches").Texts(".issue-title"), "Add patch-test.txt") { t.Error("patch not in open list") } }) t.Run("unauthenticated user is redirected to login from patch upload", func(t *testing.T) { e.anon().get("/my-repo/patches/new").mustRedirect("/login") }) t.Run("upload conflict patch", func(t *testing.T) { conflictURL = patchCreate(admin, "my-repo", "Conflict patch", "", conflictPatch). mustRedirect("/my-repo/patches/") }) t.Run("conflict patch shows apply-conflict status", func(t *testing.T) { if !admin.get(conflictURL).Has(".apply-conflict") { t.Error("apply-conflict missing") } }) t.Run("merge button absent for conflict patch", func(t *testing.T) { if n := admin.get(conflictURL).Count(`form[action*="/merge"] button`); n != 0 { t.Errorf("merge buttons = %d", n) } }) t.Run("merge clean patch changes status to merged", func(t *testing.T) { version := admin.get(cleanURL).Value(`form[action*="/merge"] input[name=version]`) admin.post(cleanURL+"/merge", url.Values{"version": {version}}).mustRedirect(cleanURL) if got := admin.get(cleanURL).Text(".patch-badge"); got != "merged" { t.Errorf("badge = %q", got) } }) t.Run("merge uses patch From header as git author", func(t *testing.T) { dir := e.repoPath("my-repo") if got := patchGitLog(t, dir, "%aN"); got != "Test User" { t.Errorf("author name = %q", got) } if got := patchGitLog(t, dir, "%aE"); got != "test@example.com" { t.Errorf("author email = %q", got) } if got := patchGitLog(t, dir, "%s"); got != "Add patch-test.txt" { t.Errorf("subject = %q", got) } }) t.Run("merged patch appears in merged list", func(t *testing.T) { if !contains(admin.get("/my-repo/patches?status=merged").Texts(".issue-title"), "Add patch-test.txt") { t.Error("patch not in merged list") } }) t.Run("upload and close a patch", func(t *testing.T) { closeURL = patchCreate(admin, "my-repo", "Close me", "", closePatch). mustRedirect("/my-repo/patches/") admin.post(closeURL+"/close", nil).mustRedirect(closeURL) if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" { t.Errorf("badge = %q", got) } }) t.Run("closed patch appears in closed list", func(t *testing.T) { if !contains(admin.get("/my-repo/patches?status=closed").Texts(".issue-title"), "Close me") { t.Error("patch not in closed list") } }) t.Run("closed patch can be reopened", func(t *testing.T) { if got := admin.get(closeURL).Text(".patch-badge"); got != "closed" { t.Fatalf("badge = %q", got) } admin.post(closeURL+"/close", nil).mustRedirect(closeURL) if got := admin.get(closeURL).Text(".patch-badge"); got != "open" { t.Errorf("badge = %q", got) } }) t.Run("patch title and description can be edited", func(t *testing.T) { // The title form resubmits the unchanged description. desc := admin.get(conflictURL).Value(`.title-edit-form [name=edit_description]`) admin.post(conflictURL+"/edit", url.Values{ "title": {"Edited Conflict Patch"}, "edit_description": {desc}, }).mustRedirect(conflictURL) if got := admin.get(conflictURL).Text(".issue-detail-title"); got != "Edited Conflict Patch" { t.Errorf("title = %q", got) } // The inline description form resubmits the unchanged title. title := admin.get(conflictURL).Value(`.inline-edit-form [name=title]`) admin.post(conflictURL+"/edit", url.Values{ "title": {title}, "edit_description": {"Updated desc"}, }).mustRedirect(conflictURL) if !strings.Contains(admin.get(conflictURL).Text(".timeline-body"), "Updated desc") { t.Error("description not updated") } }) t.Run("patch comment: add and edit", func(t *testing.T) { admin.post(conflictURL+"/comments", url.Values{"body": {"My patch comment"}}). mustRedirect(conflictURL) r := admin.get(conflictURL) bodies := r.Texts(".timeline-body") if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "My patch comment") { t.Fatalf("comment bodies = %q", bodies) } // Edit the comment through its own edit form. action, ok := patchFilterText(r, ".timeline-item", "My patch comment"). Find(".inline-edit-form").First().Attr("action") if !ok { t.Fatal("comment edit form missing") } admin.post(action, url.Values{"edit_body": {"Edited patch comment"}}).mustRedirect(conflictURL) bodies = admin.get(conflictURL).Texts(".timeline-body") if !strings.Contains(bodies[len(bodies)-1], "Edited patch comment") { t.Errorf("comment bodies = %q", bodies) } }) t.Run("patch reaction on description", func(t *testing.T) { // The picker of the first timeline item reacts on the description. emoji := admin.get(conflictURL). Find(".timeline-item").First(). Find(".reaction-picker-dropdown input[name=emoji]").First().AttrOr("value", "") if emoji == "" { t.Fatal("reaction picker empty") } admin.post(conflictURL+"/react", url.Values{"emoji": {emoji}}).mustRedirect(conflictURL) if got := admin.get(conflictURL).Text(".reaction-btn"); !strings.ContainsAny(got, "0123456789") { t.Errorf("reaction button = %q", got) } }) t.Run("admin can delete a patch", func(t *testing.T) { r := admin.post(conflictURL+"/delete", nil) r.mustStatus(http.StatusFound) if !strings.Contains(r.Location(), "/patches") { t.Errorf("location = %q", r.Location()) } admin.get(conflictURL).mustStatus(http.StatusNotFound) }) t.Run("author cannot delete own closed patch", func(t *testing.T) { alice := e.login("alice", "password123") p := patchCreate(alice, "my-repo", "Alice patch", "", uploadTestPatch). mustRedirect("/my-repo/patches/") admin.post(p+"/close", nil) alice.post(p+"/delete", nil).mustStatus(http.StatusForbidden) admin.get(p).mustStatus(http.StatusOK) admin.post(p+"/delete", nil).mustStatus(http.StatusFound) }) // ── Patch file re-upload & version protection ────────────────────────── t.Run("create patch for re-upload tests", func(t *testing.T) { uploadTestURL = patchCreate(admin, "my-repo", "Upload test patch", "", uploadTestPatch). mustRedirect("/my-repo/patches/") }) t.Run("upload patch file button is visible for admin on open patch", func(t *testing.T) { if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 1 { t.Errorf("upload details = %d", n) } }) t.Run("non-author non-admin cannot upload patch file", func(t *testing.T) { alice := e.login("alice", "password123") alice.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)). mustStatus(http.StatusForbidden) }) t.Run("upload button hidden for non-author non-admin", func(t *testing.T) { alice := e.login("alice", "password123") if n := alice.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 { t.Errorf("upload details = %d", n) } }) t.Run("admin can upload replacement patch file", func(t *testing.T) { admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(replacementPatch)). mustRedirect(uploadTestURL) }) t.Run("merge fails when version token is stale", func(t *testing.T) { stalePatch := `From e1f2a3b4c5d6e1f2a3b4c5d6e1f2a3b4c5d6e1f2 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 05 Jan 2024 10:00:00 +0000 Subject: [PATCH] Add stale-version.txt --- diff --git a/stale-version.txt b/stale-version.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/stale-version.txt @@ -0,0 +1 @@ +stale ` stalePatchV2 := strings.ReplaceAll( strings.ReplaceAll(stalePatch, "Add stale-version.txt", "Add stale-version.txt (v2)"), "+stale", "+stale v2", ) staleURL := patchCreate(admin, "my-repo", "Stale version test", "", stalePatch). mustRedirect("/my-repo/patches/") // The version the admin sees on the page. staleVersion := admin.get(staleURL).Value(`form[action*="/merge"] input[name=version]`) // The author uploads a new patch file, bumping the version. admin.postMultipart(staleURL+"/upload", nil, patchFile(stalePatchV2)).mustRedirect(staleURL) r := admin.post(staleURL+"/merge", url.Values{"version": {staleVersion}}) r.mustStatus(http.StatusConflict) if !r.Contains("updated") { t.Errorf("body = %q", r.BodyString()) } check := admin.get(staleURL).mustStatus(http.StatusOK) if !check.Contains("open") { t.Error("patch is no longer open") } }) t.Run("merge succeeds with current version token after replacement upload", func(t *testing.T) { version := admin.get(uploadTestURL).Value(`form[action*="/merge"] input[name=version]`) admin.post(uploadTestURL+"/merge", url.Values{"version": {version}}).mustRedirect(uploadTestURL) if got := admin.get(uploadTestURL).Text(".patch-badge"); got != "merged" { t.Errorf("badge = %q", got) } if got := patchGitLog(t, e.repoPath("my-repo"), "%aN"); got != "Replaced Author" { t.Errorf("author name = %q", got) } }) t.Run("upload patch file button hidden on merged patch", func(t *testing.T) { if n := admin.get(uploadTestURL).Count("details:has([name=patch_file])"); n != 0 { t.Errorf("upload details = %d", n) } }) t.Run("POST to upload on merged patch returns 400", func(t *testing.T) { admin.postMultipart(uploadTestURL+"/upload", nil, patchFile(uploadTestPatch)). mustStatus(http.StatusBadRequest) }) } // TestCommitSigning checks the signature of a patch merged by the server. // It repeats the merge the patches suite did, because each test gets a fresh // server and data directory. func TestCommitSigning(t *testing.T) { e := newEnv(t) admin := e.admin() e.createRepo(admin, "my-repo") e.seedRepo("my-repo", nil) patchURL := patchCreate(admin, "my-repo", "Add patch-test.txt", "", cleanPatch). mustRedirect("/my-repo/patches/") version := admin.get(patchURL).Value(`form[action*="/merge"] input[name=version]`) admin.post(patchURL+"/merge", url.Values{"version": {version}}).mustRedirect(patchURL) repoDir := e.repoPath("my-repo") hashOf := func(grep string) string { t.Helper() h := gitRun(t, repoDir, "log", "--format=%H", "--grep="+grep, "-1") if h == "" { t.Fatalf("no commit matching %q", grep) } return h } t.Run("allowed_signers file is generated at startup", func(t *testing.T) { content, err := os.ReadFile(e.Cfg.AllowedSignersPath()) if err != nil { t.Fatal(err) } if !strings.Contains(string(content), `namespaces="git"`) || !strings.Contains(string(content), "ssh-ed25519") { t.Errorf("allowed_signers = %q", content) } }) t.Run("merged commit has a gpgsig header", func(t *testing.T) { obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Add patch-test.txt")) if !strings.Contains(obj, "gpgsig") { t.Error("gpgsig header missing") } }) t.Run("unsigned commits have no gpgsig header", func(t *testing.T) { obj := gitRun(t, repoDir, "cat-file", "-p", hashOf("Initial commit")) if strings.Contains(obj, "gpgsig") { t.Error("gpgsig header present on unsigned commit") } }) t.Run("commit log shows verified badge on signed commit", func(t *testing.T) { r := admin.get("/my-repo/commits/main") item := patchFilterText(r, ".commit-item", "Add patch-test.txt") if item.Find(".sig-badge.verified").Length() == 0 { t.Error("verified badge missing") } }) t.Run("commit log shows no sig badge on unsigned commit", func(t *testing.T) { r := admin.get("/my-repo/commits/main") item := patchFilterText(r, ".commit-item", "Initial commit") if n := item.Find(".sig-badge").Length(); n != 0 { t.Errorf("sig badges = %d", n) } }) t.Run("commit detail shows verified signature row for signed commit", func(t *testing.T) { r := admin.get("/my-repo/commit/" + hashOf("Add patch-test.txt")) row := patchFilterText(r, ".commit-card-meta-row", "Signature") if row.Length() == 0 { t.Fatal("signature row missing") } if row.Find(".sig-badge.verified").Length() == 0 { t.Error("verified badge missing") } }) t.Run("commit detail shows no signature row for unsigned commit", func(t *testing.T) { r := admin.get("/my-repo/commit/" + hashOf("Initial commit")) if n := patchFilterText(r, ".commit-card-meta-row", "Signature").Length(); n != 0 { t.Errorf("signature rows = %d", n) } }) }