package e2e import ( "net/http" "net/url" "strings" "testing" "github.com/PuerkitoBio/goquery" ) // issuesSetup runs the shared setup of the TS file: register alice, create // my-repo and push the first commit. func issuesSetup(t *testing.T) (*env, *session, *session) { t.Helper() e := newEnv(t) alice := e.register("alice", "password123") admin := e.admin() e.createRepo(admin, "my-repo") e.seedRepo("my-repo", nil) return e, admin, alice } // issuesCreate posts the new issue form and returns the issue path. func issuesCreate(s *session, repo, title, body string) string { form := url.Values{"title": {title}} if body != "" { form.Set("body", body) } return s.post("/"+repo+"/issues", form).mustRedirect("/" + repo + "/issues/") } // issuesTimelineItem returns the timeline item whose text contains want. func issuesTimelineItem(t *testing.T, r *response, want string) *goquery.Selection { t.Helper() var found *goquery.Selection r.Find(".timeline-item").Each(func(_ int, s *goquery.Selection) { if found == nil && strings.Contains(s.Text(), want) { found = s } }) if found == nil { t.Fatalf("no timeline item containing %q", want) } return found } // issuesCommentID reads the comment id out of the inline edit form of the // timeline item that contains want. func issuesCommentID(t *testing.T, r *response, want string) string { t.Helper() item := issuesTimelineItem(t, r, want) action, ok := item.Find(`form[action*="/comments/"]`).First().Attr("action") if !ok { t.Fatalf("no comment edit form for %q", want) } rest := strings.SplitN(action, "/comments/", 2)[1] return strings.SplitN(rest, "/", 2)[0] } // issuesSaveSettings submits the repo settings form the way a browser does: // every existing field value is resent, with overrides applied on top. func issuesSaveSettings(s *session, repo string, overrides url.Values) *response { r := s.get("/" + repo + "/settings") form := url.Values{ "description": {r.Value("input[name=description]")}, "default_branch": {r.Value("[name=default_branch]")}, "issue_template": {r.Value("textarea[name=issue_template]")}, "patch_template": {r.Value("textarea[name=patch_template]")}, } for _, name := range []string{"is_private", "is_pinned", "allow_user_labels"} { if r.Has("input[name=" + name + "][checked]") { form.Set(name, "1") } } for k, vs := range overrides { form[k] = vs } return s.post("/"+repo+"/settings", form) } func TestIssues(t *testing.T) { e, admin, _ := issuesSetup(t) var issuePath, completedIssuePath string t.Run("create issue", func(t *testing.T) { issuePath = issuesCreate(admin, "my-repo", "First issue", "Body with **markdown**.") if got := admin.get(issuePath).Text(".issue-detail-title"); got != "First issue" { t.Errorf("title = %q", got) } }) t.Run("issue body renders markdown", func(t *testing.T) { r := admin.get(issuePath) html, err := r.Find(".timeline-body.markdown-body").First().Html() if err != nil { t.Fatal(err) } if !strings.Contains(html, "") { t.Errorf("body html = %q", html) } }) t.Run("issue appears in open list", func(t *testing.T) { if !contains(admin.get("/my-repo/issues").Texts(".issue-title"), "First issue") { t.Error("issue not listed") } }) t.Run("unauthenticated user is redirected to login from new issue form", func(t *testing.T) { e.anon().get("/my-repo/issues/new").mustRedirect("/login") }) t.Run("add comment", func(t *testing.T) { before := admin.get(issuePath).Count(".timeline-item") admin.post(issuePath+"/comments", url.Values{"body": {"A follow-up comment."}}). mustRedirect(issuePath) if after := admin.get(issuePath).Count(".timeline-item"); after <= before { t.Errorf("timeline items %d, want more than %d", after, before) } }) t.Run("react to issue", func(t *testing.T) { admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath) if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 { t.Error("no reaction button") } }) t.Run("close issue changes status badge", func(t *testing.T) { admin.post(issuePath+"/close", nil).mustRedirect(issuePath) if got := admin.get(issuePath).Text(".issue-badge"); got != "closed" { t.Errorf("badge = %q", got) } }) t.Run("closed issue appears in closed list", func(t *testing.T) { if !contains(admin.get("/my-repo/issues?status=closed").Texts(".issue-title"), "First issue") { t.Error("issue not in closed list") } }) t.Run("reopen issue", func(t *testing.T) { // The only action button on a closed issue reopens it. admin.post(issuePath+"/close", nil).mustRedirect(issuePath) if got := admin.get(issuePath).Text(".issue-badge"); got != "open" { t.Errorf("badge = %q", got) } }) t.Run("completed button marks issue as completed", func(t *testing.T) { completedIssuePath = issuesCreate(admin, "my-repo", "To be completed", "") admin.post(completedIssuePath+"/complete", nil).mustRedirect(completedIssuePath) if got := admin.get(completedIssuePath).Text(".issue-badge"); got != "completed" { t.Errorf("badge = %q", got) } }) t.Run("completed issue appears in completed list", func(t *testing.T) { if !contains(admin.get("/my-repo/issues?status=completed").Texts(".issue-title"), "To be completed") { t.Error("issue not in completed list") } }) t.Run("non-admin cannot complete or close issue", func(t *testing.T) { r := e.anon().post(issuePath+"/complete", nil) r.mustStatus(http.StatusFound) if !strings.Contains(r.Location(), "/login") { t.Errorf("location = %q", r.Location()) } }) t.Run("reacting with same emoji toggles it off", func(t *testing.T) { if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 { t.Fatal("no reaction to toggle") } admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath) if n := admin.get(issuePath).Count(".reaction-btn"); n != 0 { t.Errorf("reaction buttons = %d, want 0", n) } }) t.Run("react to issue comment", func(t *testing.T) { id := issuesCommentID(t, admin.get(issuePath), "A follow-up comment.") admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}, "comment_id": {id}}). mustRedirect(issuePath) item := issuesTimelineItem(t, admin.get(issuePath), "A follow-up comment.") if n := item.Find(".reaction-btn").Length(); n == 0 { t.Error("comment has no reaction button") } }) } func TestIssueEditing(t *testing.T) { e, admin, alice := issuesSetup(t) issuePath := issuesCreate(admin, "my-repo", "Issue to edit", "Original body.") t.Run("author can edit issue title and body", func(t *testing.T) { // The title form resubmits the unchanged body alongside the new title. admin.post(issuePath+"/edit", url.Values{ "title": {"Edited issue title"}, "edit_body": {"Original body."}, }).mustRedirect(issuePath) if got := admin.get(issuePath).Text(".issue-detail-title"); got != "Edited issue title" { t.Errorf("title = %q", got) } admin.post(issuePath+"/edit", url.Values{ "title": {"Edited issue title"}, "edit_body": {"Updated body text."}, }).mustRedirect(issuePath) }) t.Run("edited marker appears after editing", func(t *testing.T) { if n := admin.get(issuePath).Count(".edited-indicator"); n == 0 { t.Error("no edited indicator") } }) t.Run("non-author non-admin cannot edit issue", func(t *testing.T) { alice.post(issuePath+"/edit", url.Values{"title": {"Hacked title"}, "edit_body": {""}}). mustStatus(http.StatusForbidden) }) t.Run("author can edit issue comment", func(t *testing.T) { admin.post(issuePath+"/comments", url.Values{"body": {"Comment to edit."}}). mustRedirect(issuePath) id := issuesCommentID(t, admin.get(issuePath), "Comment to edit.") admin.post(issuePath+"/comments/"+id+"/edit", url.Values{"edit_body": {"Edited comment text."}}). mustRedirect(issuePath) bodies := admin.get(issuePath).Texts(".timeline-body") if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "Edited comment text.") { t.Errorf("last body = %q", bodies) } }) t.Run("non-admin user can create an issue", func(t *testing.T) { p := issuesCreate(alice, "my-repo", "Alice's issue", "") if got := alice.get(p).Text(".issue-detail-title"); got != "Alice's issue" { t.Errorf("title = %q", got) } }) t.Run("non-admin cannot comment on a closed issue", func(t *testing.T) { admin.post(issuePath+"/close", nil) alice.post(issuePath+"/comments", url.Values{"body": {"comment on closed issue"}}). mustStatus(http.StatusFound) if contains(admin.get(issuePath).Texts(".timeline-body"), "comment on closed issue") { t.Error("comment was stored") } }) t.Run("cannot edit comment via wrong repo url (cross-repo bypass)", func(t *testing.T) { e.createRepo(admin, "other-repo") id := issuesCommentID(t, admin.get(issuePath), "Edited comment text.") number := idFromPath(t, issuePath) admin.post("/other-repo/issues/"+number+"/comments/"+id+"/edit", url.Values{"edit_body": {"cross-repo bypass attempt"}}). mustStatus(http.StatusNotFound) if contains(admin.get(issuePath).Texts(".timeline-body"), "cross-repo bypass attempt") { t.Error("comment was changed") } }) t.Run("author can delete own issue only while open", func(t *testing.T) { closed := issuesCreate(alice, "my-repo", "Closed later", "") admin.post(closed+"/close", nil) alice.post(closed+"/delete", nil).mustStatus(http.StatusForbidden) admin.get(closed).mustStatus(http.StatusOK) open := issuesCreate(alice, "my-repo", "Still open", "") alice.post(open+"/delete", nil).mustStatus(http.StatusFound) admin.get(open).mustStatus(http.StatusNotFound) }) t.Run("admin can delete issue", func(t *testing.T) { admin.post(issuePath+"/delete", nil).mustStatus(http.StatusFound) admin.get(issuePath).mustStatus(http.StatusNotFound) }) } func TestRepoDescription(t *testing.T) { _, admin, _ := issuesSetup(t) t.Run("updating repo description is reflected on list page", func(t *testing.T) { r := issuesSaveSettings(admin, "my-repo", url.Values{"description": {"A freshly updated description"}}) if !admin.follow(r).Has(".form-success") { t.Error("no success message") } if !contains(admin.get("/").Texts(".repo-description"), "freshly updated description") { t.Error("description not on list page") } }) } func TestIssueAndPatchTemplates(t *testing.T) { _, admin, _ := issuesSetup(t) save := func(t *testing.T, field, value string) { t.Helper() r := issuesSaveSettings(admin, "my-repo", url.Values{field: {value}}) if !admin.follow(r).Has(".form-success") { t.Error("no success message") } } t.Run("issue template can be saved and is prefilled on new issue form", func(t *testing.T) { save(t, "issue_template", "## Steps to reproduce\n\n## Expected behavior") body := admin.get("/my-repo/issues/new").Value("[name=body]") if !strings.Contains(body, "## Steps to reproduce") || !strings.Contains(body, "## Expected behavior") { t.Errorf("body = %q", body) } }) t.Run("patch template can be saved and is prefilled on new patch form", func(t *testing.T) { save(t, "patch_template", "## Summary\n\n## Testing") desc := admin.get("/my-repo/patches/new").Value("[name=description]") if !strings.Contains(desc, "## Summary") || !strings.Contains(desc, "## Testing") { t.Errorf("description = %q", desc) } }) t.Run("clearing the issue template removes prefill", func(t *testing.T) { save(t, "issue_template", "") if body := admin.get("/my-repo/issues/new").Value("[name=body]"); body != "" { t.Errorf("body = %q", body) } }) t.Run("clearing the patch template removes prefill", func(t *testing.T) { save(t, "patch_template", "") if desc := admin.get("/my-repo/patches/new").Value("[name=description]"); desc != "" { t.Errorf("description = %q", desc) } }) }