internal/httpd/account_test.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/httpd/account_test.go history · blame · raw

499 lines · 15947 bytes

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