internal/httpd/issuecreate_test.go

v1.37.0
gitbay/internal/httpd/issuecreate_test.go history · blame · raw

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}