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