internal/httpd/account_test.go
579 lines · 18491 bytes
19 symbols in this file
TestAccountPagePushToggleAndDevicessubmitAccountFormTestAccountSubmitNotifyPushTestAccountSubmitDeviceRemoveTestAccountPageMasksAShortDeviceTokenassertAuditedTestPinToggleDispatchesRepoPinTestWatchToggleCyclesThroughMutednewTokenTestServerTestAccountPageListsTokensTestAccountSubmitTokenCreateShownOnceTestAccountSubmitTokenCreateScopeTestAccountSubmitTokenCreateRefusalTestAccountSubmitTokenRevokeRequiresConfirmTestAccountTokenCreateCrossSiteRefusedTestAccountSubmitTokenRevokeFlagLikeNameTestAccountTokenCreateAuditOmitsTokenTestNotificationsReadDispatchesTestAccountNotifyReply
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// The settings page carries a push toggle beside the mail and watch ones,
17// and lists registered devices by label and truncated token. The full
18// token is device-identifying and must never reach the page.
19func TestAccountPagePushToggleAndDevices(t *testing.T) {
20 st, err := store.Open(":memory:")
21 if err != nil {
22 t.Fatal(err)
23 }
24 defer st.Close()
25 if err := st.MigrateUp(); err != nil {
26 t.Fatal(err)
27 }
28
29 uid, err := st.CreateUser("alice", false)
30 if err != nil {
31 t.Fatal(err)
32 }
33 token := strings.Repeat("a", 64)
34 if _, err := st.AddPushDevice(uid, token, "iphone"); err != nil {
35 t.Fatal(err)
36 }
37
38 s := New(config.Default(), st, nil)
39 rr := httptest.NewRecorder()
40 req := httptest.NewRequest("GET", "/settings", nil)
41 s.accountPage(rr, req, store.User{ID: uid, Username: "alice"})
42
43 body := rr.Body.String()
44 if !strings.Contains(body, `value="notify-push"`) {
45 t.Fatal("no push toggle")
46 }
47 if !strings.Contains(body, "iphone") {
48 t.Fatal("the device is not listed")
49 }
50 // A token is device-identifying and is never printed in full.
51 if strings.Contains(body, token) {
52 t.Fatal("the page printed a device token in full")
53 }
54}
55
56// submit posts an account settings form as u and returns the recorder.
57func submitAccountForm(t *testing.T, s *Server, u store.User, form url.Values) *httptest.ResponseRecorder {
58 t.Helper()
59 req := httptest.NewRequest("POST", "/settings", strings.NewReader(form.Encode()))
60 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
61 rr := httptest.NewRecorder()
62 s.accountSubmit(rr, req, u)
63 return rr
64}
65
66// Posting notify-push dispatches to notifications settings push, the same
67// path the mail and watch toggles already use.
68func TestAccountSubmitNotifyPush(t *testing.T) {
69 st, err := store.Open(":memory:")
70 if err != nil {
71 t.Fatal(err)
72 }
73 defer st.Close()
74 if err := st.MigrateUp(); err != nil {
75 t.Fatal(err)
76 }
77 uid, err := st.CreateUser("alice", false)
78 if err != nil {
79 t.Fatal(err)
80 }
81 u := store.User{ID: uid, Username: "alice"}
82 s := New(config.Default(), st, nil)
83
84 rr := submitAccountForm(t, s, u, url.Values{"field": {"notify-push"}, "push": {"on"}})
85 if rr.Code != http.StatusSeeOther {
86 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
87 }
88 if on, err := st.PushEnabled(uid); err != nil || !on {
89 t.Fatalf("PushEnabled after notify-push=on: %v %v", on, err)
90 }
91
92 submitAccountForm(t, s, u, url.Values{"field": {"notify-push"}})
93 if on, err := st.PushEnabled(uid); err != nil || on {
94 t.Fatalf("PushEnabled after notify-push off: %v %v", on, err)
95 }
96}
97
98// Removing a device requires the device id typed back, and then
99// dispatches to notifications device remove, scoped to the caller's own
100// account. The id is what the form dispatches on, so the guard is
101// derived server-side the way key-remove derives its own.
102func TestAccountSubmitDeviceRemove(t *testing.T) {
103 st, err := store.Open(":memory:")
104 if err != nil {
105 t.Fatal(err)
106 }
107 defer st.Close()
108 if err := st.MigrateUp(); err != nil {
109 t.Fatal(err)
110 }
111 uid, err := st.CreateUser("alice", false)
112 if err != nil {
113 t.Fatal(err)
114 }
115 u := store.User{ID: uid, Username: "alice"}
116 token := strings.Repeat("b", 64)
117 id, err := st.AddPushDevice(uid, token, "iphone")
118 if err != nil {
119 t.Fatal(err)
120 }
121 s := New(config.Default(), st, nil)
122
123 idStr := strconv.FormatInt(id, 10)
124
125 // Without the typed confirmation, the device survives.
126 submitAccountForm(t, s, u, url.Values{"field": {"device-remove"}, "id": {idStr}})
127 if devices, _ := st.PushDevices(uid); len(devices) != 1 {
128 t.Fatalf("device removed without confirmation: %v", devices)
129 }
130
131 rr := submitAccountForm(t, s, u, url.Values{"field": {"device-remove"}, "id": {idStr}, "confirm": {idStr}})
132 if rr.Code != http.StatusSeeOther {
133 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
134 }
135 if devices, _ := st.PushDevices(uid); len(devices) != 0 {
136 t.Fatalf("device not removed: %v", devices)
137 }
138}
139
140// A short token reaches no part of the page — not the visible column,
141// and not a hidden input, aria-label or placeholder either. Device add
142// enforces no minimum length, so a token this short is a value the store
143// can hold, and it is device-identifying whatever its length.
144func TestAccountPageMasksAShortDeviceToken(t *testing.T) {
145 st, err := store.Open(":memory:")
146 if err != nil {
147 t.Fatal(err)
148 }
149 defer st.Close()
150 if err := st.MigrateUp(); err != nil {
151 t.Fatal(err)
152 }
153 uid, err := st.CreateUser("alice", false)
154 if err != nil {
155 t.Fatal(err)
156 }
157 id, err := st.AddPushDevice(uid, "abc123", "iphone")
158 if err != nil {
159 t.Fatal(err)
160 }
161
162 s := New(config.Default(), st, nil)
163 rr := httptest.NewRecorder()
164 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), store.User{ID: uid, Username: "alice"})
165
166 body := rr.Body.String()
167 if strings.Contains(body, "abc123") {
168 t.Fatalf("the short token reached the page:\n%s", body)
169 }
170 // What the removal asks for has to be on screen to be typed back.
171 idStr := strconv.FormatInt(id, 10)
172 if !strings.Contains(body, `aria-label="Type `+idStr+` to confirm"`) {
173 t.Fatalf("removal does not confirm on the device id:\n%s", body)
174 }
175 if !strings.Contains(body, `<th scope="col">id</th>`) {
176 t.Fatalf("the device table has no id column:\n%s", body)
177 }
178}
179
180// assertAudited fails the test unless an audit row with the given action
181// prefix exists — proof a handler dispatched through the control
182// registry rather than writing the store directly, since only Dispatch
183// itself calls Store.Audit.
184func assertAudited(t *testing.T, st *store.Store, prefix string) {
185 t.Helper()
186 entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: prefix, Limit: 10})
187 if err != nil {
188 t.Fatal(err)
189 }
190 if len(entries) == 0 {
191 t.Fatalf("no audit row with action prefix %q", prefix)
192 }
193}
194
195// Pinning writes through the repo pin command, not the store directly,
196// so it carries the same audit trail and write budget as every other
197// mutating command (#261).
198func TestPinToggleDispatchesRepoPin(t *testing.T) {
199 st, err := store.Open(":memory:")
200 if err != nil {
201 t.Fatal(err)
202 }
203 defer st.Close()
204 if err := st.MigrateUp(); err != nil {
205 t.Fatal(err)
206 }
207 uid, err := st.CreateUser("alice", false)
208 if err != nil {
209 t.Fatal(err)
210 }
211 u := store.User{ID: uid, Username: "alice"}
212 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
213 t.Fatal(err)
214 }
215
216 s := New(config.Default(), st, nil)
217 req := httptest.NewRequest("POST", "/alice/app/pin", nil)
218 req.SetPathValue("owner", "alice")
219 req.SetPathValue("repo", "app")
220 rr := httptest.NewRecorder()
221 s.pinToggle(rr, req, u)
222
223 repo, err := st.RepoByPath("alice/app")
224 if err != nil {
225 t.Fatal(err)
226 }
227 if !st.IsPinned(uid, repo.ID) {
228 t.Fatal("pin did not take effect")
229 }
230 assertAudited(t, st, "cmd repo pin")
231
232 rr2 := httptest.NewRecorder()
233 s.pinToggle(rr2, req, u)
234 if st.IsPinned(uid, repo.ID) {
235 t.Fatal("second toggle should have unpinned")
236 }
237 assertAudited(t, st, "cmd repo unpin")
238}
239
240// The watch button cycles default, watching, muted — the three states
241// repo watch/repo mute/repo unwatch already support — rather than the
242// two the store-writing version offered (#261, #271).
243func TestWatchToggleCyclesThroughMuted(t *testing.T) {
244 st, err := store.Open(":memory:")
245 if err != nil {
246 t.Fatal(err)
247 }
248 defer st.Close()
249 if err := st.MigrateUp(); err != nil {
250 t.Fatal(err)
251 }
252 uid, err := st.CreateUser("alice", false)
253 if err != nil {
254 t.Fatal(err)
255 }
256 u := store.User{ID: uid, Username: "alice"}
257 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
258 t.Fatal(err)
259 }
260 repo, err := st.RepoByPath("alice/app")
261 if err != nil {
262 t.Fatal(err)
263 }
264
265 s := New(config.Default(), st, nil)
266 req := httptest.NewRequest("POST", "/alice/app/watch", nil)
267 req.SetPathValue("owner", "alice")
268 req.SetPathValue("repo", "app")
269
270 click := func() string {
271 rr := httptest.NewRecorder()
272 s.watchToggle(rr, req, u)
273 return st.RepoWatchState(repo.ID, uid)
274 }
275 if got := click(); got != "watching" {
276 t.Fatalf("first click: got %q, want watching", got)
277 }
278 assertAudited(t, st, "cmd repo watch")
279 if got := click(); got != "muted" {
280 t.Fatalf("second click: got %q, want muted", got)
281 }
282 assertAudited(t, st, "cmd repo mute")
283 if got := click(); got != "" {
284 t.Fatalf("third click: got %q, want default (unwatched)", got)
285 }
286 assertAudited(t, st, "cmd repo unwatch")
287}
288
289// newTokenTestServer is a server over a fresh store with one user.
290func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
291 t.Helper()
292 st, err := store.Open(":memory:")
293 if err != nil {
294 t.Fatal(err)
295 }
296 t.Cleanup(func() { st.Close() })
297 if err := st.MigrateUp(); err != nil {
298 t.Fatal(err)
299 }
300 uid, err := st.CreateUser("alice", false)
301 if err != nil {
302 t.Fatal(err)
303 }
304 return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice", SignedInAt: time.Now()}
305}
306
307// The settings page lists a user's API tokens with scope and expiry,
308// never the hash (#264).
309func TestAccountPageListsTokens(t *testing.T) {
310 s, st, u := newTokenTestServer(t)
311 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
312 t.Fatal(err)
313 }
314 rr := httptest.NewRecorder()
315 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
316 body := rr.Body.String()
317 if !strings.Contains(body, "<td>laptop</td>") || !strings.Contains(body, "<td>read</td>") {
318 t.Fatalf("token row missing: %s", body)
319 }
320 if strings.Contains(body, "somehash") {
321 t.Fatal("the page printed a token hash")
322 }
323}
324
325// Creating a token answers the POST itself with the token, marked
326// no-store, and puts it in no header: not a Location, not a cookie. A
327// later GET of the page does not show it (#264).
328func TestAccountSubmitTokenCreateShownOnce(t *testing.T) {
329 s, st, u := newTokenTestServer(t)
330 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}})
331 if rr.Code != http.StatusOK {
332 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
333 }
334 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
335 t.Errorf("Cache-Control = %q, want no-store", got)
336 }
337 body := rr.Body.String()
338 i := strings.Index(body, "gb_")
339 if i < 0 {
340 t.Fatalf("token not shown: %s", body)
341 }
342 token := body[i:]
343 token = token[:strings.IndexAny(token, "<\n")]
344 for name, vals := range rr.Header() {
345 for _, v := range vals {
346 if strings.Contains(v, token) {
347 t.Errorf("header %s carries the token", name)
348 }
349 }
350 }
351 got, tk, err := st.APITokenUser(store.HashToken(token))
352 if err != nil || got.ID != u.ID || tk.Name != "laptop" || tk.Scope != "full" {
353 t.Fatalf("shown token does not resolve: %+v %+v %v", got, tk, err)
354 }
355
356 rr = httptest.NewRecorder()
357 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
358 if strings.Contains(rr.Body.String(), token) {
359 t.Fatal("a later GET showed the token")
360 }
361 if !strings.Contains(rr.Body.String(), "<td>laptop</td>") {
362 t.Fatal("the new token is not listed")
363 }
364}
365
366// The form sends --scope explicitly, read unless full was picked, so the
367// page does not depend on token create's own default (#264, #257).
368func TestAccountSubmitTokenCreateScope(t *testing.T) {
369 s, st, u := newTokenTestServer(t)
370 for _, c := range []struct{ name, scope, want string }{
371 {"a", "", "read"}, {"b", "read", "read"}, {"c", "bogus", "read"}, {"d", "full", "full"},
372 } {
373 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {c.name}, "scope": {c.scope}})
374 if rr.Code != http.StatusOK {
375 t.Fatalf("%s: status %d", c.name, rr.Code)
376 }
377 }
378 tokens, err := st.ListAPITokens(u.ID)
379 if err != nil || len(tokens) != 4 {
380 t.Fatalf("tokens: %v %v", tokens, err)
381 }
382 want := map[string]string{"a": "read", "b": "read", "c": "read", "d": "full"}
383 for _, tk := range tokens {
384 if tk.Scope != want[tk.Name] {
385 t.Errorf("%s: scope %q, want %q", tk.Name, tk.Scope, want[tk.Name])
386 }
387 }
388}
389
390// A failed create redirects with the reason and shows no token.
391func TestAccountSubmitTokenCreateRefusal(t *testing.T) {
392 s, _, u := newTokenTestServer(t)
393 for _, form := range []url.Values{
394 {"field": {"token-create"}, "name": {""}},
395 {"field": {"token-create"}, "name": {"x"}, "ttl": {"-1h"}},
396 } {
397 rr := submitAccountForm(t, s, u, form)
398 if rr.Code != http.StatusSeeOther || strings.Contains(rr.Body.String(), "gb_") {
399 t.Errorf("%v: status %d, body %s", form, rr.Code, rr.Body.String())
400 }
401 }
402}
403
404// Revoking a token requires the name typed back, the same guard every
405// other removal on this page uses.
406func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
407 s, st, u := newTokenTestServer(t)
408 if err := st.CreateAPIToken(u.ID, "laptop", "somehash", "read", nil, 0); err != nil {
409 t.Fatal(err)
410 }
411 submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
412 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 1 {
413 t.Fatal("token revoked without confirmation")
414 }
415 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
416 if rr.Code != http.StatusSeeOther {
417 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
418 }
419 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
420 t.Fatal("token not revoked")
421 }
422}
423
424// A cross-site POST to /settings is refused before a token is minted.
425func TestAccountTokenCreateCrossSiteRefused(t *testing.T) {
426 _, st, u := newTokenTestServer(t)
427 cfg := config.Default()
428 cfg.Web.Mode = "accounts"
429 s := New(cfg, st, nil)
430 form := url.Values{"field": {"token-create"}, "name": {"evil"}, "scope": {"full"}}
431 for _, r := range s.Routes() {
432 if r.Method != "POST" || r.Pattern != "/settings" {
433 continue
434 }
435 req := httptest.NewRequest("POST", "http://example.com/settings", strings.NewReader(form.Encode()))
436 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
437 req.Header.Set("Origin", "https://evil.example")
438 rr := httptest.NewRecorder()
439 r.Handler(rr, req)
440 if rr.Code != http.StatusForbidden {
441 t.Fatalf("status %d, want 403", rr.Code)
442 }
443 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
444 t.Fatal("a cross-site POST minted a token")
445 }
446 return
447 }
448 t.Fatal("no POST /settings route")
449}
450
451// A token named like a flag, which token create accepts, can still be
452// revoked from the page: the name goes after "--".
453func TestAccountSubmitTokenRevokeFlagLikeName(t *testing.T) {
454 s, st, u := newTokenTestServer(t)
455 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"--x"}})
456 if rr.Code != http.StatusOK {
457 t.Fatalf("create: status %d, body %s", rr.Code, rr.Body.String())
458 }
459 rr = submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"--x"}, "confirm": {"--x"}})
460 if rr.Code != http.StatusSeeOther {
461 t.Fatalf("revoke: status %d", rr.Code)
462 }
463 if tokens, _ := st.ListAPITokens(u.ID); len(tokens) != 0 {
464 t.Fatalf("token not revoked: %+v", tokens)
465 }
466}
467
468// The audit row for a web token create records the command but not the
469// minted token.
470func TestAccountTokenCreateAuditOmitsToken(t *testing.T) {
471 s, st, u := newTokenTestServer(t)
472 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
473 if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "gb_") {
474 t.Fatalf("create: status %d", rr.Code)
475 }
476 rows, err := st.DB.Query("SELECT action, data_json FROM audit_log")
477 if err != nil {
478 t.Fatal(err)
479 }
480 defer rows.Close()
481 found := false
482 for rows.Next() {
483 var action, data string
484 if err := rows.Scan(&action, &data); err != nil {
485 t.Fatal(err)
486 }
487 if action == "cmd token create" {
488 found = true
489 }
490 if strings.Contains(data, "gb_") {
491 t.Errorf("audit row %q carries the token: %s", action, data)
492 }
493 }
494 if err := rows.Err(); err != nil {
495 t.Fatal(err)
496 }
497 if !found {
498 t.Fatal("no cmd token create audit row")
499 }
500}
501
502// Marking notices read goes through notifications read, so it carries the
503// audit trail and write budget of the CLI command (#261).
504func TestNotificationsReadDispatches(t *testing.T) {
505 st, err := store.Open(":memory:")
506 if err != nil {
507 t.Fatal(err)
508 }
509 defer st.Close()
510 if err := st.MigrateUp(); err != nil {
511 t.Fatal(err)
512 }
513 uid, err := st.CreateUser("alice", false)
514 if err != nil {
515 t.Fatal(err)
516 }
517 u := store.User{ID: uid, Username: "alice"}
518 repoID, err := st.CreateRepo("user", uid, "app", "public")
519 if err != nil {
520 t.Fatal(err)
521 }
522 for i := 0; i < 2; i++ {
523 if err := st.AddNotice(uid, repoID, "issue", "bob", "s", "alice/app/issues/1"); err != nil {
524 t.Fatal(err)
525 }
526 }
527 s := New(config.Default(), st, nil)
528
529 notices, _ := st.Inbox(uid, true, 10, 0)
530 req := httptest.NewRequest("POST", "/notifications/read", strings.NewReader("id="+strconv.FormatInt(notices[0].ID, 10)))
531 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
532 s.notificationsRead(httptest.NewRecorder(), req, u)
533 if n := st.UnreadNotices(uid); n != 1 {
534 t.Fatalf("unread after one id = %d, want 1", n)
535 }
536 assertAudited(t, st, "cmd notifications read")
537
538 req = httptest.NewRequest("POST", "/notifications/read", nil)
539 s.notificationsRead(httptest.NewRecorder(), req, u)
540 if n := st.UnreadNotices(uid); n != 0 {
541 t.Fatalf("unread after all = %d, want 0", n)
542 }
543}
544
545// The reply toggle is offered only where the instance reads replies, and
546// posting it dispatches notifications settings reply (#295).
547func TestAccountNotifyReply(t *testing.T) {
548 st, err := store.Open(":memory:")
549 if err != nil {
550 t.Fatal(err)
551 }
552 defer st.Close()
553 if err := st.MigrateUp(); err != nil {
554 t.Fatal(err)
555 }
556 uid, err := st.CreateUser("alice", false)
557 if err != nil {
558 t.Fatal(err)
559 }
560 u := store.User{ID: uid, Username: "alice"}
561 page := func(s *Server) string {
562 rr := httptest.NewRecorder()
563 s.accountPage(rr, httptest.NewRequest("GET", "/settings", nil), u)
564 return rr.Body.String()
565 }
566 if strings.Contains(page(New(config.Default(), st, nil)), `value="notify-reply"`) {
567 t.Fatal("reply toggle offered without [mail.inbound]")
568 }
569 cfg := config.Default()
570 cfg.Mail.Inbound.Enabled = true
571 s := New(cfg, st, nil)
572 if !strings.Contains(page(s), `value="notify-reply"`) {
573 t.Fatal("no reply toggle")
574 }
575 submitAccountForm(t, s, u, url.Values{"field": {"notify-reply"}, "reply": {"on"}})
576 if on, err := st.ReplyEnabled(uid); err != nil || !on {
577 t.Fatalf("ReplyEnabled after notify-reply=on: %v %v", on, err)
578 }
579}