| @@ -284,3 +284,216 @@ func TestWatchToggleCyclesThroughMuted(t *testing.T) { |
| 284 | 284 | } |
| 285 | 285 | assertAudited(t, st, "cmd repo unwatch") |
| 286 | 286 | } |
| 287 | |
| 288 | // newTokenTestServer is a server over a fresh store with one user. |
| 289 | func 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), 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). |
| 308 | func 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). |
| 327 | func 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). |
| 367 | func 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. |
| 390 | func 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. |
| 405 | func 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. |
| 424 | func TestAccountTokenCreateCrossSiteRefused(t *testing.T) { |
| 425 | _, st, u := newTokenTestServer(t) |
| 426 | cfg := config.Default() |
| 427 | cfg.Web.Mode = "accounts" |
| 428 | s := New(cfg, st) |
| 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 "--". |
| 452 | func 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. |
| 469 | func 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 | } |