internal/httpd/parity296b_test.go

v1.43.1
gitbay/internal/httpd/parity296b_test.go history · blame · raw

396 lines · 14133 bytes

  1package httpd
  2
  3import (
  4	"bytes"
  5	"io"
  6	"mime/multipart"
  7	"net/http"
  8	"net/http/httptest"
  9	"net/url"
 10	"strings"
 11	"testing"
 12
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16type p296b struct {
 17	s      *Server
 18	st     *store.Store
 19	h      http.Handler
 20	repo   store.Repo
 21	orgID  int64
 22	alice  *http.Cookie
 23	bob    *http.Cookie
 24	aliceU store.User
 25}
 26
 27func newP296b(t *testing.T) *p296b {
 28	t.Helper()
 29	e := newSettingsEnv(t)
 30	e.s.cfg.Limits.MaxAssetBytes = 1 << 10
 31	orgID, err := e.st.CreateOrg("acme", e.alice.ID)
 32	if err != nil {
 33		t.Fatal(err)
 34	}
 35	if err := e.st.SetOrgMember(orgID, e.bob.ID, "member"); err != nil {
 36		t.Fatal(err)
 37	}
 38	if _, err := e.st.CreateRelease(e.repo.ID, "v1", "One", "", e.alice.ID, "md"); err != nil {
 39		t.Fatal(err)
 40	}
 41	return &p296b{s: e.s, st: e.st, h: e.s.Handler(), repo: e.repo, orgID: orgID,
 42		alice: sessionCookieFor(t, e.s, e.st, e.alice.ID),
 43		bob:   sessionCookieFor(t, e.s, e.st, e.bob.ID), aliceU: e.alice}
 44}
 45
 46func (p *p296b) do(method, path string, ck *http.Cookie, body io.Reader, ctype string) *httptest.ResponseRecorder {
 47	req := httptest.NewRequest(method, path, body)
 48	if ctype != "" {
 49		req.Header.Set("Content-Type", ctype)
 50	}
 51	req.AddCookie(ck)
 52	rr := httptest.NewRecorder()
 53	p.h.ServeHTTP(rr, req)
 54	return rr
 55}
 56
 57func (p *p296b) post(path string, ck *http.Cookie, f url.Values) *httptest.ResponseRecorder {
 58	return p.do("POST", path, ck, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded")
 59}
 60
 61// follow returns the page a redirect points at, carrying the flash.
 62func (p *p296b) follow(rr *httptest.ResponseRecorder, ck *http.Cookie) string {
 63	req := httptest.NewRequest("GET", rr.Header().Get("Location"), nil)
 64	req.AddCookie(ck)
 65	for _, c := range rr.Result().Cookies() {
 66		req.AddCookie(c)
 67	}
 68	out := httptest.NewRecorder()
 69	p.h.ServeHTTP(out, req)
 70	return out.Body.String()
 71}
 72
 73func (p *p296b) get(path string, ck *http.Cookie) string {
 74	return p.do("GET", path, ck, nil, "").Body.String()
 75}
 76
 77func upload(t *testing.T, fields map[string]string, fname string, data []byte) (io.Reader, string) {
 78	t.Helper()
 79	var buf bytes.Buffer
 80	mw := multipart.NewWriter(&buf)
 81	for k, v := range fields {
 82		mw.WriteField(k, v)
 83	}
 84	if fname != "" {
 85		fw, _ := mw.CreateFormFile("file", fname)
 86		fw.Write(data)
 87	}
 88	mw.Close()
 89	return &buf, mw.FormDataContentType()
 90}
 91
 92func TestReleaseAssetUploadAndRemove(t *testing.T) {
 93	p := newP296b(t)
 94	const path = "/alice/app/releases/assets"
 95	body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
 96	rr := p.do("POST", path, p.alice, body, ct)
 97	if rr.Code != http.StatusSeeOther {
 98		t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
 99	}
100	rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1")
101	if len(rel.Assets) != 1 || rel.Assets[0].Name != "tool.bin" || rel.Assets[0].Size != 7 {
102		t.Fatalf("assets %+v", rel.Assets)
103	}
104	page := p.get("/alice/app/releases", p.alice)
105	for _, want := range []string{"Add asset", `enctype="multipart/form-data"`, "tool.bin", `value="remove"`} {
106		if !strings.Contains(page, want) {
107			t.Errorf("writer page lacks %q", want)
108		}
109	}
110
111	// Refusals: over the limit, empty, bad name, no file.
112	for name, tc := range map[string]struct {
113		fname string
114		data  []byte
115		field map[string]string
116		want  string
117	}{
118		"oversized":  {"big.bin", bytes.Repeat([]byte("x"), 2<<10), map[string]string{"tag": "v1"}, "max_asset_bytes"},
119		"way over":   {"huge.bin", bytes.Repeat([]byte("x"), 3<<20), map[string]string{"tag": "v1"}, "max_asset_bytes"},
120		"empty":      {"e.bin", nil, map[string]string{"tag": "v1"}, "empty"},
121		"bad name":   {"x.bin", []byte("x"), map[string]string{"tag": "v1", "name": ".hidden"}, "invalid asset name"},
122		"no file":    {"", nil, map[string]string{"tag": "v1"}, "choose a file"},
123		"no release": {"a.bin", []byte("x"), map[string]string{"tag": "v9"}, ""},
124	} {
125		body, ct := upload(t, tc.field, tc.fname, tc.data)
126		rr := p.do("POST", path, p.alice, body, ct)
127		if tc.want == "" {
128			if rr.Code != http.StatusNotFound {
129				t.Errorf("%s: %d", name, rr.Code)
130			}
131			continue
132		}
133		if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), tc.want) {
134			t.Errorf("%s: %d, no %q on the page", name, rr.Code, tc.want)
135		}
136	}
137	if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 {
138		t.Fatalf("a refused upload stored something: %+v", rel.Assets)
139	}
140
141	// Remove needs the name typed.
142	rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}})
143	if !strings.Contains(p.follow(rr, p.alice), "type tool.bin to confirm") {
144		t.Fatal("no confirmation demanded")
145	}
146	if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 {
147		t.Fatal("removed without confirmation")
148	}
149	p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}, "confirm": {"tool.bin"}})
150	if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 {
151		t.Fatalf("not removed: %+v", rel.Assets)
152	}
153}
154
155func TestReleaseAssetReaderSeesNoFormAndIsRefused(t *testing.T) {
156	p := newP296b(t)
157	body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
158	rr := p.do("POST", "/alice/app/releases/assets", p.bob, body, ct)
159	if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) {
160		t.Fatalf("no refusal shown: %d", rr.Code)
161	}
162	if rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 {
163		t.Fatal("a reader uploaded an asset")
164	}
165	page := p.get("/alice/app/releases", p.bob)
166	if strings.Contains(page, "Add asset") || strings.Contains(page, "releases/assets") {
167		t.Fatal("reader sees the asset form")
168	}
169}
170
171func TestRepoMilestoneCreateCloseReopen(t *testing.T) {
172	p := newP296b(t)
173	const path = "/alice/app/milestones"
174	rr := p.post(path, p.alice, url.Values{"title": {"v2"}, "description": {"next"}, "due": {"2026-12-01"}})
175	if rr.Code != http.StatusSeeOther {
176		t.Fatalf("create: %d", rr.Code)
177	}
178	m, err := p.st.MilestoneByTitle(p.repo, "v2")
179	if err != nil || m.Description != "next" || m.DueDate != "2026-12-01" {
180		t.Fatalf("%+v %v", m, err)
181	}
182	page := p.get(path, p.alice)
183	for _, want := range []string{"New milestone", `value="close"`} {
184		if !strings.Contains(page, want) {
185			t.Errorf("page lacks %q", want)
186		}
187	}
188	p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"v2"}})
189	if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "closed" {
190		t.Fatalf("state %q", m.State)
191	}
192	p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"v2"}})
193	if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "open" {
194		t.Fatalf("state %q", m.State)
195	}
196
197	// Refusals show on the page.
198	rr = p.post(path, p.alice, url.Values{"title": {"v3"}, "due": {"soon"}})
199	if !strings.Contains(p.follow(rr, p.alice), "--due must be YYYY-MM-DD") {
200		t.Fatal("bad date not reported")
201	}
202	rr = p.post(path, p.alice, url.Values{"title": {"v2"}})
203	if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), `role="alert"`) {
204		t.Fatal("duplicate not reported")
205	}
206}
207
208func TestRepoMilestoneReaderSeesNoForm(t *testing.T) {
209	p := newP296b(t)
210	page := p.get("/alice/app/milestones", p.bob)
211	if strings.Contains(page, "New milestone") || strings.Contains(page, `action="/alice/app/milestones"`) {
212		t.Fatal("reader sees the form")
213	}
214	rr := p.post("/alice/app/milestones", p.bob, url.Values{"title": {"sneaky"}})
215	if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) {
216		t.Fatal("no refusal")
217	}
218	if _, err := p.st.MilestoneByTitle(p.repo, "sneaky"); err == nil {
219		t.Fatal("a reader created a milestone")
220	}
221}
222
223func TestOrgLabelSetAndRemove(t *testing.T) {
224	p := newP296b(t)
225	const path = "/acme/-/labels"
226	rr := p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"d73a4a"}})
227	if rr.Code != http.StatusSeeOther {
228		t.Fatalf("set: %d", rr.Code)
229	}
230	labels, _ := p.st.ListOrgLabels(p.orgID, nil)
231	if len(labels) != 1 || labels[0].Name != "bug" || labels[0].Color != "#d73a4a" {
232		t.Fatalf("%+v", labels)
233	}
234	page := p.get(path, p.alice)
235	for _, want := range []string{"New org label", `value="remove"`, `aria-label="Colour for bug"`} {
236		if !strings.Contains(page, want) {
237			t.Errorf("page lacks %q", want)
238		}
239	}
240	rr = p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"zzz"}})
241	if !strings.Contains(p.follow(rr, p.alice), "--color takes rrggbb") {
242		t.Fatal("bad colour not reported")
243	}
244	rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}})
245	if !strings.Contains(p.follow(rr, p.alice), "type bug to confirm") {
246		t.Fatal("no confirmation demanded")
247	}
248	if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 1 {
249		t.Fatal("removed without confirmation")
250	}
251	p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}})
252	if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 {
253		t.Fatalf("not removed: %+v", labels)
254	}
255}
256
257func TestOrgLabelMemberSeesNoFormAndIsRefused(t *testing.T) {
258	p := newP296b(t)
259	page := p.get("/acme/-/labels", p.bob)
260	if strings.Contains(page, "New org label") || strings.Contains(page, `action="/acme/-/labels"`) {
261		t.Fatal("member sees the form")
262	}
263	rr := p.post("/acme/-/labels", p.bob, url.Values{"name": {"bug"}})
264	if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") {
265		t.Fatalf("no refusal: %d", rr.Code)
266	}
267	if labels, _ := p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 {
268		t.Fatal("a member created an org label")
269	}
270}
271
272func TestOrgMilestoneCreateCloseReopen(t *testing.T) {
273	p := newP296b(t)
274	const path = "/acme/-/milestones"
275	if rr := p.post(path, p.alice, url.Values{"title": {"mobile"}, "due": {"2026-12-01"}}); rr.Code != http.StatusSeeOther {
276		t.Fatalf("create: %d", rr.Code)
277	}
278	state := func(s string) int {
279		ms, _ := p.st.ListOrgMilestones(p.orgID, s, nil)
280		return len(ms)
281	}
282	if state("open") != 1 {
283		t.Fatal("not created")
284	}
285	if page := p.get(path, p.alice); !strings.Contains(page, "New org milestone") || !strings.Contains(page, `value="close"`) {
286		t.Fatal("admin page lacks the controls")
287	}
288	p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"mobile"}})
289	if state("closed") != 1 || state("open") != 0 {
290		t.Fatal("not closed")
291	}
292	p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"mobile"}})
293	if state("open") != 1 {
294		t.Fatal("not reopened")
295	}
296	rr := p.post(path, p.alice, url.Values{"title": {"mobile"}})
297	if !strings.Contains(p.follow(rr, p.alice), `role="alert"`) {
298		t.Fatal("duplicate not reported")
299	}
300}
301
302func TestOrgMilestoneMemberSeesNoFormAndIsRefused(t *testing.T) {
303	p := newP296b(t)
304	page := p.get("/acme/-/milestones", p.bob)
305	if strings.Contains(page, "New org milestone") || strings.Contains(page, `action="/acme/-/milestones"`) {
306		t.Fatal("member sees the form")
307	}
308	rr := p.post("/acme/-/milestones", p.bob, url.Values{"title": {"sneaky"}})
309	if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") {
310		t.Fatalf("no refusal: %d", rr.Code)
311	}
312	if ms, _ := p.st.ListOrgMilestones(p.orgID, "all", nil); len(ms) != 0 {
313		t.Fatal("a member created an org milestone")
314	}
315}
316
317// countingBody reports how many bytes a handler read from a request.
318type countingBody struct {
319	r io.Reader
320	n int
321}
322
323func (c *countingBody) Read(b []byte) (int, error) {
324	n, err := c.r.Read(b)
325	c.n += n
326	return n, err
327}
328
329func TestReleaseAssetAuthorisesBeforeReading(t *testing.T) {
330	p := newP296b(t)
331	if _, err := p.st.CreateRepo("user", p.aliceU.ID, "secret", "private"); err != nil {
332		t.Fatal(err)
333	}
334	payload, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", bytes.Repeat([]byte("x"), 512))
335	raw, _ := io.ReadAll(payload)
336	send := func(path string, ck *http.Cookie, length int64) (*httptest.ResponseRecorder, *countingBody) {
337		body := &countingBody{r: bytes.NewReader(raw)}
338		req := httptest.NewRequest("POST", path, body)
339		req.ContentLength = length
340		req.Header.Set("Content-Type", ct)
341		req.AddCookie(ck)
342		rr := httptest.NewRecorder()
343		p.h.ServeHTTP(rr, req)
344		return rr, body
345	}
346	for _, path := range []string{"/alice/secret/releases/assets", "/alice/nothing/releases/assets"} {
347		rr, body := send(path, p.bob, int64(len(raw)))
348		if rr.Code != http.StatusNotFound || body.n != 0 {
349			t.Errorf("%s: %d, read %d bytes", path, rr.Code, body.n)
350		}
351	}
352	// A reader of a public repo is refused without reading too.
353	rr, body := send("/alice/app/releases/assets", p.bob, int64(len(raw)))
354	if rr.Code != http.StatusSeeOther || body.n != 0 {
355		t.Errorf("reader: %d, read %d bytes", rr.Code, body.n)
356	}
357	// An announced length over the cap is refused before reading.
358	rr, body = send("/alice/app/releases/assets", p.alice, 3<<20)
359	if rr.Code != http.StatusSeeOther || body.n != 0 || !strings.Contains(p.follow(rr, p.alice), "max_asset_bytes") {
360		t.Errorf("oversized: %d, read %d bytes", rr.Code, body.n)
361	}
362}
363
364func TestReleaseAssetOneUploadAtATime(t *testing.T) {
365	p := newP296b(t)
366	p.s.uploads.Store(p.aliceU.ID, struct{}{})
367	body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
368	rr := p.do("POST", "/alice/app/releases/assets", p.alice, body, ct)
369	if !strings.Contains(p.follow(rr, p.alice), "still running") {
370		t.Fatalf("second upload not refused: %d", rr.Code)
371	}
372	p.s.uploads.Delete(p.aliceU.ID)
373	body, ct = upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
374	if rr = p.do("POST", "/alice/app/releases/assets", p.alice, body, ct); rr.Code != http.StatusSeeOther {
375		t.Fatal(rr.Code)
376	}
377	if _, busy := p.s.uploads.Load(p.aliceU.ID); busy {
378		t.Fatal("slot not released")
379	}
380}
381
382func TestMilestoneTitleStartingWithDash(t *testing.T) {
383	p := newP296b(t)
384	p.post("/alice/app/milestones", p.alice, url.Values{"title": {"--due"}})
385	if _, err := p.st.MilestoneByTitle(p.repo, "--due"); err != nil {
386		t.Fatalf("repo milestone: %v", err)
387	}
388	p.post("/acme/-/milestones", p.alice, url.Values{"title": {"--description"}})
389	if ms, _ := p.st.ListOrgMilestones(p.orgID, "open", nil); len(ms) != 1 || ms[0].Title != "--description" {
390		t.Fatalf("org milestone: %+v", ms)
391	}
392	p.post("/acme/-/labels", p.alice, url.Values{"name": {"--color"}})
393	if ls, _ := p.st.ListOrgLabels(p.orgID, nil); len(ls) != 1 || ls[0].Name != "--color" {
394		t.Fatalf("org label: %+v", ls)
395	}
396}