internal/httpd/account_test.go

v1.38.0
gitbay/internal/httpd/account_test.go history · blame · raw

543 lines · 17351 bytes

  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}