internal/httpd/issuecreate_test.go
438 lines · 12963 bytes
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/store"
14 "gitbay.org/gitbay/internal/web"
15)
16
17// A heading precedes the issue's comment thread, matching the merge
18// request page, so a screen-reader user skimming by heading has a
19// landmark before the first comment rather than falling straight from
20// the edit box into the body (#271).
21func TestIssuePageHasDiscussionHeading(t *testing.T) {
22 var sb strings.Builder
23 if err := web.Render(&sb, "issue.html", struct {
24 repoPage
25 Issue store.Issue
26 BodyHTML template.HTML
27 Comments []renderedComment
28 CanEdit bool
29 CanWrite bool
30 Milestones []store.Milestone
31 Notice string
32 LabelColors map[string]template.CSS
33 Draft *draft
34 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil {
35 t.Fatalf("render: %v", err)
36 }
37 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
38 t.Error("no Discussion heading")
39 }
40}
41
42// sessionCookieFor gives uid a real web session, the way canWriteRepo's
43// call to s.viewer(r) needs (internal/httpd/accounts.go:37-47), since
44// issueCreateForm gates the milestone/assignee fields on it rather than
45// on the handler's own user parameter.
46func sessionCookieFor(t *testing.T, s *Server, st *store.Store, uid int64) *http.Cookie {
47 t.Helper()
48 tok, hash, err := store.NewToken()
49 if err != nil {
50 t.Fatal(err)
51 }
52 if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
53 t.Fatal(err)
54 }
55 return s.sessionCookieFor(tok)
56}
57
58// The new-issue form takes milestone and assignee, resolved on the same
59// issue create dispatch as the title and labels, so a typo in either
60// creates nothing and the label/milestone/assign code paths still run
61// notifications and events (#271).
62func TestIssueCreateFormHasMilestoneAndAssigneeForWriter(t *testing.T) {
63 st, err := store.Open(":memory:")
64 if err != nil {
65 t.Fatal(err)
66 }
67 defer st.Close()
68 if err := st.MigrateUp(); err != nil {
69 t.Fatal(err)
70 }
71 uid, err := st.CreateUser("alice", false)
72 if err != nil {
73 t.Fatal(err)
74 }
75 u := store.User{ID: uid, Username: "alice"}
76 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
77 t.Fatal(err)
78 }
79
80 cfg := config.Default()
81 cfg.Web.Mode = "accounts"
82 s := New(cfg, st, nil)
83 req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
84 req.SetPathValue("owner", "alice")
85 req.SetPathValue("repo", "app")
86 req.AddCookie(sessionCookieFor(t, s, st, uid))
87 rr := httptest.NewRecorder()
88 s.issueCreateForm(rr, req, u)
89
90 body := rr.Body.String()
91 if !strings.Contains(body, `name="labels"`) {
92 t.Error("no labels field for a writer")
93 }
94 if !strings.Contains(body, `name="milestone"`) {
95 t.Error("no milestone field for a writer")
96 }
97 if !strings.Contains(body, `name="assignee"`) {
98 t.Error("no assignee field for a writer")
99 }
100}
101
102// A reader (no write access) sees no milestone/assignee fields, and can
103// still create an issue with title and body alone.
104func TestIssueCreateFormHidesMilestoneAndAssigneeForReader(t *testing.T) {
105 st, err := store.Open(":memory:")
106 if err != nil {
107 t.Fatal(err)
108 }
109 defer st.Close()
110 if err := st.MigrateUp(); err != nil {
111 t.Fatal(err)
112 }
113 ownerID, err := st.CreateUser("alice", false)
114 if err != nil {
115 t.Fatal(err)
116 }
117 readerID, err := st.CreateUser("bob", false)
118 if err != nil {
119 t.Fatal(err)
120 }
121 reader := store.User{ID: readerID, Username: "bob"}
122 if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
123 t.Fatal(err)
124 }
125
126 cfg := config.Default()
127 cfg.Web.Mode = "accounts"
128 s := New(cfg, st, nil)
129 req := httptest.NewRequest("GET", "/alice/app/issues/new", nil)
130 req.SetPathValue("owner", "alice")
131 req.SetPathValue("repo", "app")
132 req.AddCookie(sessionCookieFor(t, s, st, readerID))
133 rr := httptest.NewRecorder()
134 s.issueCreateForm(rr, req, reader)
135
136 body := rr.Body.String()
137 if strings.Contains(body, `name="labels"`) {
138 t.Error("reader should not see a labels field")
139 }
140 if strings.Contains(body, `name="milestone"`) {
141 t.Error("reader should not see a milestone field")
142 }
143 if strings.Contains(body, `name="assignee"`) {
144 t.Error("reader should not see an assignee field")
145 }
146
147 form := url.Values{"title": {"a bug"}, "body": {"steps"}}
148 submit := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
149 submit.Header.Set("Content-Type", "application/x-www-form-urlencoded")
150 submit.SetPathValue("owner", "alice")
151 submit.SetPathValue("repo", "app")
152 rr2 := httptest.NewRecorder()
153 s.issueCreateSubmit(rr2, submit, reader)
154 if rr2.Code != http.StatusSeeOther {
155 t.Fatalf("reader create: status %d, body %q", rr2.Code, rr2.Body.String())
156 }
157
158 repo, err := st.RepoByPath("alice/app")
159 if err != nil {
160 t.Fatal(err)
161 }
162 issue, err := st.IssueByNumber(repo.ID, 1)
163 if err != nil {
164 t.Fatalf("issue not created: %v", err)
165 }
166 if issue.Title != "a bug" {
167 t.Fatalf("got title %q", issue.Title)
168 }
169}
170
171// A reader's hand-crafted POST carrying a labels value still creates a
172// plain issue: issue create requires write access for --label, so
173// issueCreateSubmit drops labels/milestone/assignee from the argv for a
174// non-writer rather than sending them and failing the whole create.
175func TestIssueCreateSubmitReaderLabelIsDropped(t *testing.T) {
176 st, err := store.Open(":memory:")
177 if err != nil {
178 t.Fatal(err)
179 }
180 defer st.Close()
181 if err := st.MigrateUp(); err != nil {
182 t.Fatal(err)
183 }
184 ownerID, err := st.CreateUser("alice", false)
185 if err != nil {
186 t.Fatal(err)
187 }
188 readerID, err := st.CreateUser("bob", false)
189 if err != nil {
190 t.Fatal(err)
191 }
192 reader := store.User{ID: readerID, Username: "bob"}
193 if _, err := st.CreateRepo("user", ownerID, "app", "public"); err != nil {
194 t.Fatal(err)
195 }
196 repo, err := st.RepoByPath("alice/app")
197 if err != nil {
198 t.Fatal(err)
199 }
200
201 s := New(config.Default(), st, nil)
202 form := url.Values{
203 "title": {"a bug"},
204 "body": {"steps"},
205 "labels": {"bug"},
206 }
207 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
208 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
209 req.SetPathValue("owner", "alice")
210 req.SetPathValue("repo", "app")
211 rr := httptest.NewRecorder()
212 s.issueCreateSubmit(rr, req, reader)
213 if rr.Code != http.StatusSeeOther {
214 t.Fatalf("reader create: status %d, body %q", rr.Code, rr.Body.String())
215 }
216
217 issue, err := st.IssueByNumber(repo.ID, 1)
218 if err != nil {
219 t.Fatalf("issue not created: %v", err)
220 }
221 if len(issue.Labels) != 0 {
222 t.Errorf("labels = %v, want none", issue.Labels)
223 }
224}
225
226// A writer creates an issue with a milestone and an assignee in one
227// request; both land on the issue because they go through the same
228// dispatch as the create.
229func TestIssueCreateSubmitSetsMilestoneAndAssignee(t *testing.T) {
230 st, err := store.Open(":memory:")
231 if err != nil {
232 t.Fatal(err)
233 }
234 defer st.Close()
235 if err := st.MigrateUp(); err != nil {
236 t.Fatal(err)
237 }
238 uid, err := st.CreateUser("alice", false)
239 if err != nil {
240 t.Fatal(err)
241 }
242 u := store.User{ID: uid, Username: "alice"}
243 if _, err := st.CreateUser("bob", false); err != nil {
244 t.Fatal(err)
245 }
246 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
247 t.Fatal(err)
248 }
249 repo, err := st.RepoByPath("alice/app")
250 if err != nil {
251 t.Fatal(err)
252 }
253 if _, err := st.CreateMilestone(repo, "v1", "", ""); err != nil {
254 t.Fatal(err)
255 }
256
257 s := New(config.Default(), st, nil)
258 form := url.Values{
259 "title": {"needs a fix"},
260 "body": {"details"},
261 "milestone": {"v1"},
262 "assignee": {"bob"},
263 }
264 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
265 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
266 req.SetPathValue("owner", "alice")
267 req.SetPathValue("repo", "app")
268 rr := httptest.NewRecorder()
269 s.issueCreateSubmit(rr, req, u)
270 if rr.Code != http.StatusSeeOther {
271 t.Fatalf("status %d, body %q", rr.Code, rr.Body.String())
272 }
273
274 issue, err := st.IssueByNumber(repo.ID, 1)
275 if err != nil {
276 t.Fatalf("issue not created: %v", err)
277 }
278 if issue.Milestone != "v1" {
279 t.Errorf("milestone = %q, want v1", issue.Milestone)
280 }
281 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
282 t.Errorf("assignees = %v, want [bob]", issue.Assignees)
283 }
284}
285
286// Preview carries the milestone and assignee back into the form, the same
287// way it already carries title and labels, so a writer previewing the
288// body does not lose what they picked (#271).
289func TestIssueCreateFormPreviewKeepsMilestoneAndAssignee(t *testing.T) {
290 st, err := store.Open(":memory:")
291 if err != nil {
292 t.Fatal(err)
293 }
294 defer st.Close()
295 if err := st.MigrateUp(); err != nil {
296 t.Fatal(err)
297 }
298 uid, err := st.CreateUser("alice", false)
299 if err != nil {
300 t.Fatal(err)
301 }
302 u := store.User{ID: uid, Username: "alice"}
303 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
304 t.Fatal(err)
305 }
306
307 cfg := config.Default()
308 cfg.Web.Mode = "accounts"
309 s := New(cfg, st, nil)
310 form := url.Values{
311 "title": {"a bug"},
312 "body": {"**steps**"},
313 "milestone": {"v1"},
314 "assignee": {"bob"},
315 "preview": {"1"},
316 }
317 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
318 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
319 req.SetPathValue("owner", "alice")
320 req.SetPathValue("repo", "app")
321 req.AddCookie(sessionCookieFor(t, s, st, uid))
322 rr := httptest.NewRecorder()
323 s.issueCreateSubmit(rr, req, u)
324
325 body := rr.Body.String()
326 if !strings.Contains(body, `value="v1"`) {
327 t.Errorf("preview lost the milestone:\n%s", body)
328 }
329 if !strings.Contains(body, `value="bob"`) {
330 t.Errorf("preview lost the assignee:\n%s", body)
331 }
332}
333
334// A refused create — here a bad milestone — re-renders the new-issue form
335// with the draft and a notice, rather than an http.Error page that drops
336// everything the visitor typed (#271).
337func TestIssueCreateSubmitRefusedKeepsDraft(t *testing.T) {
338 st, err := store.Open(":memory:")
339 if err != nil {
340 t.Fatal(err)
341 }
342 defer st.Close()
343 if err := st.MigrateUp(); err != nil {
344 t.Fatal(err)
345 }
346 uid, err := st.CreateUser("alice", false)
347 if err != nil {
348 t.Fatal(err)
349 }
350 u := store.User{ID: uid, Username: "alice"}
351 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
352 t.Fatal(err)
353 }
354 repo, err := st.RepoByPath("alice/app")
355 if err != nil {
356 t.Fatal(err)
357 }
358
359 s := New(config.Default(), st, nil)
360 form := url.Values{
361 "title": {"needs a fix"},
362 "body": {"details"},
363 "milestone": {"no-such-milestone"},
364 }
365 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
366 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
367 req.SetPathValue("owner", "alice")
368 req.SetPathValue("repo", "app")
369 rr := httptest.NewRecorder()
370 s.issueCreateSubmit(rr, req, u)
371
372 if rr.Code == http.StatusSeeOther {
373 t.Fatalf("expected a failure status, got redirect")
374 }
375 body := rr.Body.String()
376 if !strings.Contains(body, `value="needs a fix"`) {
377 t.Errorf("refused create lost the title:\n%s", body)
378 }
379 if !strings.Contains(body, "details") {
380 t.Errorf("refused create lost the body:\n%s", body)
381 }
382 if !strings.Contains(body, `class="error"`) {
383 t.Errorf("refused create has no notice:\n%s", body)
384 }
385
386 if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
387 t.Fatal("issue was created despite the bad milestone")
388 }
389}
390
391// A bad assignee creates nothing: issue create resolves the assignee
392// before writing the issue, so a typo leaves the repo without a
393// half-created issue (#271).
394func TestIssueCreateSubmitBadAssigneeCreatesNothing(t *testing.T) {
395 st, err := store.Open(":memory:")
396 if err != nil {
397 t.Fatal(err)
398 }
399 defer st.Close()
400 if err := st.MigrateUp(); err != nil {
401 t.Fatal(err)
402 }
403 uid, err := st.CreateUser("alice", false)
404 if err != nil {
405 t.Fatal(err)
406 }
407 u := store.User{ID: uid, Username: "alice"}
408 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
409 t.Fatal(err)
410 }
411 repo, err := st.RepoByPath("alice/app")
412 if err != nil {
413 t.Fatal(err)
414 }
415
416 s := New(config.Default(), st, nil)
417 form := url.Values{
418 "title": {"needs a fix"},
419 "body": {"details"},
420 "assignee": {"nobody-such-user"},
421 }
422 req := httptest.NewRequest("POST", "/alice/app/issues/new", strings.NewReader(form.Encode()))
423 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
424 req.SetPathValue("owner", "alice")
425 req.SetPathValue("repo", "app")
426 rr := httptest.NewRecorder()
427 s.issueCreateSubmit(rr, req, u)
428 if rr.Code == http.StatusSeeOther {
429 t.Fatalf("expected a failure status, got redirect")
430 }
431 if !strings.Contains(rr.Body.String(), "nobody-such-user") {
432 t.Errorf("error body %q does not name the bad assignee", rr.Body.String())
433 }
434
435 if _, err := st.IssueByNumber(repo.ID, 1); err == nil {
436 t.Fatal("issue was created despite the bad assignee")
437 }
438}