issues_test.go
⎇
Raw
1package e2e
2
3import (
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.
14func 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.
25func 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.
34func 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.
50func 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.
63func 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
82func 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
195func 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
280func 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
295func 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