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