issues_test.go
| 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/PuerkitoBio/goquery" |
| 10 | ) |
| 11 | |
| 12 | // issuesSetup runs the shared setup of the TS file: register alice, create |
| 13 | // my-repo and push the first commit. |
| 14 | func issuesSetup(t *testing.T) (*env, *session, *session) { |
| 15 | t.Helper() |
| 16 | e := newEnv(t) |
| 17 | alice := e.register("alice", "password123") |
| 18 | admin := e.admin() |
| 19 | e.createRepo(admin, "my-repo") |
| 20 | e.seedRepo("my-repo", nil) |
| 21 | return e, admin, alice |
| 22 | } |
| 23 | |
| 24 | // issuesCreate posts the new issue form and returns the issue path. |
| 25 | func issuesCreate(s *session, repo, title, body string) string { |
| 26 | form := url.Values{"title": {title}} |
| 27 | if body != "" { |
| 28 | form.Set("body", body) |
| 29 | } |
| 30 | return s.post("/"+repo+"/issues", form).mustRedirect("/" + repo + "/issues/") |
| 31 | } |
| 32 | |
| 33 | // issuesTimelineItem returns the timeline item whose text contains want. |
| 34 | func issuesTimelineItem(t *testing.T, r *response, want string) *goquery.Selection { |
| 35 | t.Helper() |
| 36 | var found *goquery.Selection |
| 37 | r.Find(".timeline-item").Each(func(_ int, s *goquery.Selection) { |
| 38 | if found == nil && strings.Contains(s.Text(), want) { |
| 39 | found = s |
| 40 | } |
| 41 | }) |
| 42 | if found == nil { |
| 43 | t.Fatalf("no timeline item containing %q", want) |
| 44 | } |
| 45 | return found |
| 46 | } |
| 47 | |
| 48 | // issuesCommentID reads the comment id out of the inline edit form of the |
| 49 | // timeline item that contains want. |
| 50 | func issuesCommentID(t *testing.T, r *response, want string) string { |
| 51 | t.Helper() |
| 52 | item := issuesTimelineItem(t, r, want) |
| 53 | action, ok := item.Find(`form[action*="/comments/"]`).First().Attr("action") |
| 54 | if !ok { |
| 55 | t.Fatalf("no comment edit form for %q", want) |
| 56 | } |
| 57 | rest := strings.SplitN(action, "/comments/", 2)[1] |
| 58 | return strings.SplitN(rest, "/", 2)[0] |
| 59 | } |
| 60 | |
| 61 | // issuesSaveSettings submits the repo settings form the way a browser does: |
| 62 | // every existing field value is resent, with overrides applied on top. |
| 63 | func issuesSaveSettings(s *session, repo string, overrides url.Values) *response { |
| 64 | r := s.get("/" + repo + "/settings") |
| 65 | form := url.Values{ |
| 66 | "description": {r.Value("input[name=description]")}, |
| 67 | "default_branch": {r.Value("[name=default_branch]")}, |
| 68 | "issue_template": {r.Value("textarea[name=issue_template]")}, |
| 69 | "patch_template": {r.Value("textarea[name=patch_template]")}, |
| 70 | } |
| 71 | for _, name := range []string{"is_private", "is_pinned", "allow_user_labels"} { |
| 72 | if r.Has("input[name=" + name + "][checked]") { |
| 73 | form.Set(name, "1") |
| 74 | } |
| 75 | } |
| 76 | for k, vs := range overrides { |
| 77 | form[k] = vs |
| 78 | } |
| 79 | return s.post("/"+repo+"/settings", form) |
| 80 | } |
| 81 | |
| 82 | func TestIssues(t *testing.T) { |
| 83 | e, admin, _ := issuesSetup(t) |
| 84 | |
| 85 | var issuePath, completedIssuePath string |
| 86 | |
| 87 | t.Run("create issue", func(t *testing.T) { |
| 88 | issuePath = issuesCreate(admin, "my-repo", "First issue", "Body with **markdown**.") |
| 89 | if got := admin.get(issuePath).Text(".issue-detail-title"); got != "First issue" { |
| 90 | t.Errorf("title = %q", got) |
| 91 | } |
| 92 | }) |
| 93 | |
| 94 | t.Run("issue body renders markdown", func(t *testing.T) { |
| 95 | r := admin.get(issuePath) |
| 96 | html, err := r.Find(".timeline-body.markdown-body").First().Html() |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if !strings.Contains(html, "<strong>") { |
| 101 | t.Errorf("body html = %q", html) |
| 102 | } |
| 103 | }) |
| 104 | |
| 105 | t.Run("issue appears in open list", func(t *testing.T) { |
| 106 | if !contains(admin.get("/my-repo/issues").Texts(".issue-title"), "First issue") { |
| 107 | t.Error("issue not listed") |
| 108 | } |
| 109 | }) |
| 110 | |
| 111 | t.Run("unauthenticated user is redirected to login from new issue form", func(t *testing.T) { |
| 112 | e.anon().get("/my-repo/issues/new").mustRedirect("/login") |
| 113 | }) |
| 114 | |
| 115 | t.Run("add comment", func(t *testing.T) { |
| 116 | before := admin.get(issuePath).Count(".timeline-item") |
| 117 | admin.post(issuePath+"/comments", url.Values{"body": {"A follow-up comment."}}). |
| 118 | mustRedirect(issuePath) |
| 119 | if after := admin.get(issuePath).Count(".timeline-item"); after <= before { |
| 120 | t.Errorf("timeline items %d, want more than %d", after, before) |
| 121 | } |
| 122 | }) |
| 123 | |
| 124 | t.Run("react to issue", func(t *testing.T) { |
| 125 | admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath) |
| 126 | if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 { |
| 127 | t.Error("no reaction button") |
| 128 | } |
| 129 | }) |
| 130 | |
| 131 | t.Run("close issue changes status badge", func(t *testing.T) { |
| 132 | admin.post(issuePath+"/close", nil).mustRedirect(issuePath) |
| 133 | if got := admin.get(issuePath).Text(".issue-badge"); got != "closed" { |
| 134 | t.Errorf("badge = %q", got) |
| 135 | } |
| 136 | }) |
| 137 | |
| 138 | t.Run("closed issue appears in closed list", func(t *testing.T) { |
| 139 | if !contains(admin.get("/my-repo/issues?status=closed").Texts(".issue-title"), "First issue") { |
| 140 | t.Error("issue not in closed list") |
| 141 | } |
| 142 | }) |
| 143 | |
| 144 | t.Run("reopen issue", func(t *testing.T) { |
| 145 | // The only action button on a closed issue reopens it. |
| 146 | admin.post(issuePath+"/close", nil).mustRedirect(issuePath) |
| 147 | if got := admin.get(issuePath).Text(".issue-badge"); got != "open" { |
| 148 | t.Errorf("badge = %q", got) |
| 149 | } |
| 150 | }) |
| 151 | |
| 152 | t.Run("completed button marks issue as completed", func(t *testing.T) { |
| 153 | completedIssuePath = issuesCreate(admin, "my-repo", "To be completed", "") |
| 154 | admin.post(completedIssuePath+"/complete", nil).mustRedirect(completedIssuePath) |
| 155 | if got := admin.get(completedIssuePath).Text(".issue-badge"); got != "completed" { |
| 156 | t.Errorf("badge = %q", got) |
| 157 | } |
| 158 | }) |
| 159 | |
| 160 | t.Run("completed issue appears in completed list", func(t *testing.T) { |
| 161 | if !contains(admin.get("/my-repo/issues?status=completed").Texts(".issue-title"), "To be completed") { |
| 162 | t.Error("issue not in completed list") |
| 163 | } |
| 164 | }) |
| 165 | |
| 166 | t.Run("non-admin cannot complete or close issue", func(t *testing.T) { |
| 167 | r := e.anon().post(issuePath+"/complete", nil) |
| 168 | r.mustStatus(http.StatusFound) |
| 169 | if !strings.Contains(r.Location(), "/login") { |
| 170 | t.Errorf("location = %q", r.Location()) |
| 171 | } |
| 172 | }) |
| 173 | |
| 174 | t.Run("reacting with same emoji toggles it off", func(t *testing.T) { |
| 175 | if n := admin.get(issuePath).Count(".reaction-btn"); n == 0 { |
| 176 | t.Fatal("no reaction to toggle") |
| 177 | } |
| 178 | admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}}).mustRedirect(issuePath) |
| 179 | if n := admin.get(issuePath).Count(".reaction-btn"); n != 0 { |
| 180 | t.Errorf("reaction buttons = %d, want 0", n) |
| 181 | } |
| 182 | }) |
| 183 | |
| 184 | t.Run("react to issue comment", func(t *testing.T) { |
| 185 | id := issuesCommentID(t, admin.get(issuePath), "A follow-up comment.") |
| 186 | admin.post(issuePath+"/react", url.Values{"emoji": {"👍"}, "comment_id": {id}}). |
| 187 | mustRedirect(issuePath) |
| 188 | item := issuesTimelineItem(t, admin.get(issuePath), "A follow-up comment.") |
| 189 | if n := item.Find(".reaction-btn").Length(); n == 0 { |
| 190 | t.Error("comment has no reaction button") |
| 191 | } |
| 192 | }) |
| 193 | } |
| 194 | |
| 195 | func TestIssueEditing(t *testing.T) { |
| 196 | e, admin, alice := issuesSetup(t) |
| 197 | issuePath := issuesCreate(admin, "my-repo", "Issue to edit", "Original body.") |
| 198 | |
| 199 | t.Run("author can edit issue title and body", func(t *testing.T) { |
| 200 | // The title form resubmits the unchanged body alongside the new title. |
| 201 | admin.post(issuePath+"/edit", url.Values{ |
| 202 | "title": {"Edited issue title"}, "edit_body": {"Original body."}, |
| 203 | }).mustRedirect(issuePath) |
| 204 | if got := admin.get(issuePath).Text(".issue-detail-title"); got != "Edited issue title" { |
| 205 | t.Errorf("title = %q", got) |
| 206 | } |
| 207 | admin.post(issuePath+"/edit", url.Values{ |
| 208 | "title": {"Edited issue title"}, "edit_body": {"Updated body text."}, |
| 209 | }).mustRedirect(issuePath) |
| 210 | }) |
| 211 | |
| 212 | t.Run("edited marker appears after editing", func(t *testing.T) { |
| 213 | if n := admin.get(issuePath).Count(".edited-indicator"); n == 0 { |
| 214 | t.Error("no edited indicator") |
| 215 | } |
| 216 | }) |
| 217 | |
| 218 | t.Run("non-author non-admin cannot edit issue", func(t *testing.T) { |
| 219 | alice.post(issuePath+"/edit", url.Values{"title": {"Hacked title"}, "edit_body": {""}}). |
| 220 | mustStatus(http.StatusForbidden) |
| 221 | }) |
| 222 | |
| 223 | t.Run("author can edit issue comment", func(t *testing.T) { |
| 224 | admin.post(issuePath+"/comments", url.Values{"body": {"Comment to edit."}}). |
| 225 | mustRedirect(issuePath) |
| 226 | id := issuesCommentID(t, admin.get(issuePath), "Comment to edit.") |
| 227 | admin.post(issuePath+"/comments/"+id+"/edit", url.Values{"edit_body": {"Edited comment text."}}). |
| 228 | mustRedirect(issuePath) |
| 229 | bodies := admin.get(issuePath).Texts(".timeline-body") |
| 230 | if len(bodies) == 0 || !strings.Contains(bodies[len(bodies)-1], "Edited comment text.") { |
| 231 | t.Errorf("last body = %q", bodies) |
| 232 | } |
| 233 | }) |
| 234 | |
| 235 | t.Run("non-admin user can create an issue", func(t *testing.T) { |
| 236 | p := issuesCreate(alice, "my-repo", "Alice's issue", "") |
| 237 | if got := alice.get(p).Text(".issue-detail-title"); got != "Alice's issue" { |
| 238 | t.Errorf("title = %q", got) |
| 239 | } |
| 240 | }) |
| 241 | |
| 242 | t.Run("non-admin cannot comment on a closed issue", func(t *testing.T) { |
| 243 | admin.post(issuePath+"/close", nil) |
| 244 | alice.post(issuePath+"/comments", url.Values{"body": {"comment on closed issue"}}). |
| 245 | mustStatus(http.StatusFound) |
| 246 | if contains(admin.get(issuePath).Texts(".timeline-body"), "comment on closed issue") { |
| 247 | t.Error("comment was stored") |
| 248 | } |
| 249 | }) |
| 250 | |
| 251 | t.Run("cannot edit comment via wrong repo url (cross-repo bypass)", func(t *testing.T) { |
| 252 | e.createRepo(admin, "other-repo") |
| 253 | id := issuesCommentID(t, admin.get(issuePath), "Edited comment text.") |
| 254 | number := idFromPath(t, issuePath) |
| 255 | admin.post("/other-repo/issues/"+number+"/comments/"+id+"/edit", |
| 256 | url.Values{"edit_body": {"cross-repo bypass attempt"}}). |
| 257 | mustStatus(http.StatusNotFound) |
| 258 | if contains(admin.get(issuePath).Texts(".timeline-body"), "cross-repo bypass attempt") { |
| 259 | t.Error("comment was changed") |
| 260 | } |
| 261 | }) |
| 262 | |
| 263 | t.Run("author can delete own issue only while open", func(t *testing.T) { |
| 264 | closed := issuesCreate(alice, "my-repo", "Closed later", "") |
| 265 | admin.post(closed+"/close", nil) |
| 266 | alice.post(closed+"/delete", nil).mustStatus(http.StatusForbidden) |
| 267 | admin.get(closed).mustStatus(http.StatusOK) |
| 268 | |
| 269 | open := issuesCreate(alice, "my-repo", "Still open", "") |
| 270 | alice.post(open+"/delete", nil).mustStatus(http.StatusFound) |
| 271 | admin.get(open).mustStatus(http.StatusNotFound) |
| 272 | }) |
| 273 | |
| 274 | t.Run("admin can delete issue", func(t *testing.T) { |
| 275 | admin.post(issuePath+"/delete", nil).mustStatus(http.StatusFound) |
| 276 | admin.get(issuePath).mustStatus(http.StatusNotFound) |
| 277 | }) |
| 278 | } |
| 279 | |
| 280 | func TestRepoDescription(t *testing.T) { |
| 281 | _, admin, _ := issuesSetup(t) |
| 282 | |
| 283 | t.Run("updating repo description is reflected on list page", func(t *testing.T) { |
| 284 | r := issuesSaveSettings(admin, "my-repo", |
| 285 | url.Values{"description": {"A freshly updated description"}}) |
| 286 | if !admin.follow(r).Has(".form-success") { |
| 287 | t.Error("no success message") |
| 288 | } |
| 289 | if !contains(admin.get("/").Texts(".repo-description"), "freshly updated description") { |
| 290 | t.Error("description not on list page") |
| 291 | } |
| 292 | }) |
| 293 | } |
| 294 | |
| 295 | func TestIssueAndPatchTemplates(t *testing.T) { |
| 296 | _, admin, _ := issuesSetup(t) |
| 297 | |
| 298 | save := func(t *testing.T, field, value string) { |
| 299 | t.Helper() |
| 300 | r := issuesSaveSettings(admin, "my-repo", url.Values{field: {value}}) |
| 301 | if !admin.follow(r).Has(".form-success") { |
| 302 | t.Error("no success message") |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | t.Run("issue template can be saved and is prefilled on new issue form", func(t *testing.T) { |
| 307 | save(t, "issue_template", "## Steps to reproduce\n\n## Expected behavior") |
| 308 | body := admin.get("/my-repo/issues/new").Value("[name=body]") |
| 309 | if !strings.Contains(body, "## Steps to reproduce") || !strings.Contains(body, "## Expected behavior") { |
| 310 | t.Errorf("body = %q", body) |
| 311 | } |
| 312 | }) |
| 313 | |
| 314 | t.Run("patch template can be saved and is prefilled on new patch form", func(t *testing.T) { |
| 315 | save(t, "patch_template", "## Summary\n\n## Testing") |
| 316 | desc := admin.get("/my-repo/patches/new").Value("[name=description]") |
| 317 | if !strings.Contains(desc, "## Summary") || !strings.Contains(desc, "## Testing") { |
| 318 | t.Errorf("description = %q", desc) |
| 319 | } |
| 320 | }) |
| 321 | |
| 322 | t.Run("clearing the issue template removes prefill", func(t *testing.T) { |
| 323 | save(t, "issue_template", "") |
| 324 | if body := admin.get("/my-repo/issues/new").Value("[name=body]"); body != "" { |
| 325 | t.Errorf("body = %q", body) |
| 326 | } |
| 327 | }) |
| 328 | |
| 329 | t.Run("clearing the patch template removes prefill", func(t *testing.T) { |
| 330 | save(t, "patch_template", "") |
| 331 | if desc := admin.get("/my-repo/patches/new").Value("[name=description]"); desc != "" { |
| 332 | t.Errorf("description = %q", desc) |
| 333 | } |
| 334 | }) |
| 335 | } |
| 336 |