internal/httpd/issuecreate_test.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/httpd/issuecreate_test.go history · blame · raw

439 lines · 13040 bytes

9 symbols in this file
  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}