internal/httpd/parity296b_test.go
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}