Files
verdande/internal/httpapi/auth_test.go
T
Kristian WindandClaude Opus 5 06ab10a276 fix(httpapi): a broken database must not look like a failed login
authenticate() cannot tell "no such session" from "the session lookup
failed" on its own — both arrive as an error. Answering 401 for both
meant a disk or file-permission problem presented as every session
ending at once: the frontend clears its state and shows the sign-in
screen to everybody, and the logs record a wave of failed logins rather
than the fault.

Now only a missing or unknown credential is 401. Anything else is a 500
and is logged as what it is.

Found while writing the smoke tests, where a deleted database file
produced SQLITE_CANTOPEN on the connections that had not been opened
yet — so most requests succeeded and a few reported the user as signed
out, which reads exactly like a flaky auth bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:56:33 +02:00

764 lines
24 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// testServer runs the real router against a real database over a real listener.
// Auth is mostly cookies, status codes and middleware ordering — none of which a
// unit test of the handler functions would exercise.
type testServer struct {
*httptest.Server
db *store.DB
client *http.Client
}
func newTestServer(t *testing.T) *testServer {
t.Helper()
return newTestServerWith(t, nil)
}
// newTestServerWith lets a test vary the configuration — a registered OAuth client,
// update checking on — without every other test paying for it.
func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
ResetTTL: time.Hour,
Dev: true,
}
if configure != nil {
configure(cfg)
}
// Discard: a passing test should print nothing, and these handlers log freely.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(New(cfg, db, log, nil))
t.Cleanup(srv.Close)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &testServer{
Server: srv,
db: db,
// The cookie jar is what makes this a session test rather than a
// sequence of unrelated requests.
client: &http.Client{Jar: jar, Timeout: 10 * time.Second},
}
}
func (ts *testServer) do(t *testing.T, method, path string, body any) (*http.Response, map[string]any) {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, ts.URL+path, reader)
if err != nil {
t.Fatalf("build request: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Same-origin, as a browser would report for the app's own fetches.
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { resp.Body.Close() })
var decoded map[string]any
raw, _ := io.ReadAll(resp.Body)
if len(raw) > 0 {
_ = json.Unmarshal(raw, &decoded)
}
return resp, decoded
}
// bootstrap creates the first admin and leaves the client signed in.
func (ts *testServer) bootstrap(t *testing.T) {
t.Helper()
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("bootstrap: status %d", resp.StatusCode)
}
}
func TestSetupCreatesTheFirstAdminOnlyOnce(t *testing.T) {
ts := newTestServer(t)
resp, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
if resp.StatusCode != http.StatusOK || body["needs_setup"] != true {
t.Fatalf("a fresh instance should need setup: %d %v", resp.StatusCode, body)
}
resp, body = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("setup: status %d, body %v", resp.StatusCode, body)
}
user, _ := body["user"].(map[string]any)
if user == nil || user["is_admin"] != true {
t.Errorf("the first account is not an admin: %v", body)
}
// The password hash must never appear in a response.
if _, leaked := user["password_hash"]; leaked {
t.Error("the response carries the password hash")
}
// A second attempt must be refused, or anyone could add themselves as admin.
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "someone@else.dk", "name": "Someone", "password": "et andet kodeord",
})
if resp.StatusCode != http.StatusConflict {
t.Errorf("second setup: status %d, want 409", resp.StatusCode)
}
resp, body = ts.do(t, "GET", "/api/v1/auth/setup", nil)
if body["needs_setup"] != false {
t.Errorf("instance still reports needing setup: %v", body)
}
}
func TestSetupCreatesAnInbox(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
if _, err := ts.db.InboxID(t.Context(), user.ID); err != nil {
t.Errorf("no Inbox was created for the first user: %v", err)
}
}
func TestLoginAndLogout(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Signed in from bootstrap.
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK || body["email"] != "kristian@example.dk" {
t.Fatalf("me: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/logout", nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("logout: status %d", resp.StatusCode)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("me after logout: status %d, want 401", resp.StatusCode)
}
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %d %v", resp.StatusCode, body)
}
if body["totp_required"] != false {
t.Errorf("login asked for a code with 2FA off: %v", body)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("me after login: status %d", resp.StatusCode)
}
}
// The address is normalised, so one account cannot be reached under two spellings
// and, worse, a second account cannot be created that shadows the first.
func TestLoginIgnoresEmailCaseAndSpace(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
for _, email := range []string{
"kristian@example.dk", "KRISTIAN@example.dk", " Kristian@Example.DK ",
} {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": email, "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Errorf("login as %q: status %d", email, resp.StatusCode)
}
ts.do(t, "POST", "/api/v1/auth/logout", nil)
}
}
// A wrong password and an address with no account must be indistinguishable, or the
// login form becomes a way to find out who has an account here.
func TestLoginDoesNotRevealWhichAccountsExist(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
respWrong, bodyWrong := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "forkert kodeord",
})
respMissing, bodyMissing := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "findes-ikke@example.dk", "password": "forkert kodeord",
})
if respWrong.StatusCode != respMissing.StatusCode {
t.Errorf("status differs: %d vs %d", respWrong.StatusCode, respMissing.StatusCode)
}
if bodyWrong["error"] != bodyMissing["error"] || bodyWrong["code"] != bodyMissing["code"] {
t.Errorf("message differs:\n wrong password: %v\n no account: %v", bodyWrong, bodyMissing)
}
}
func TestSessionCookieIsHardened(t *testing.T) {
ts := newTestServer(t)
// Read the cookie off the response, not out of the jar: Go's cookiejar keeps
// only name and value, so every attribute checked here would come back unset
// regardless of what the server actually sent.
resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("setup: status %d", resp.StatusCode)
}
var session *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == sessionCookieInsecure || c.Name == sessionCookieSecure {
session = c
}
}
if session == nil {
t.Fatal("no session cookie was set")
}
if !session.HttpOnly {
t.Error("session cookie is readable from JavaScript")
}
if session.SameSite != http.SameSiteLaxMode {
t.Errorf("SameSite = %v, want Lax", session.SameSite)
}
if session.Path != "/" {
t.Errorf("Path = %q, want /", session.Path)
}
// The stored value must be a hash, never the cookie itself: a database dump
// must not contain anything that can be pasted into a browser.
var stored string
err := ts.db.QueryRow(`SELECT id FROM sessions LIMIT 1`).Scan(&stored)
if err != nil {
t.Fatal(err)
}
if stored == session.Value {
t.Error("the session token is stored verbatim; it must be hashed")
}
}
func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Begin: a secret exists, but 2FA is not on yet — closing the tab here must
// not lock the account.
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
if secret == "" {
t.Fatal("no secret returned")
}
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
if me["totp_enabled"] != false {
t.Error("2FA switched on before the code was confirmed")
}
// Confirm with a real code.
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
codes, _ := body["recovery_codes"].([]any)
if len(codes) == 0 {
t.Fatal("no recovery codes were issued")
}
// Now a login is two steps.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK || body["totp_required"] != true {
t.Fatalf("login should ask for a code: %d %v", resp.StatusCode, body)
}
// The half-finished session must not reach anything.
resp, body = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a pending session reached /me: %d", resp.StatusCode)
}
if body["code"] != CodeTOTPRequired {
t.Errorf("code = %v, want %s", body["code"], CodeTOTPRequired)
}
// A wrong code does not finish the login.
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"})
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a wrong code was accepted: %d", resp.StatusCode)
}
// The right one does.
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp login: %d %v", resp.StatusCode, body)
}
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Errorf("me after completing 2FA: %d", resp.StatusCode)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil)
secret := body["secret"].(string)
_, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
raw := body["recovery_codes"].([]any)
recovery := raw[0].(string)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, _ := ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
if resp.StatusCode != http.StatusOK {
t.Fatalf("recovery code rejected: %d", resp.StatusCode)
}
// The same code must not work a second time.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery})
if resp.StatusCode == http.StatusOK {
t.Error("a recovery code worked twice")
}
}
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
"current_password": "det forkerte", "new_password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("wrong current password: status %d, want 422", resp.StatusCode)
}
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{
"current_password": "et langt kodeord", "new_password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("change password: status %d", resp.StatusCode)
}
// The session that made the change survives; the old password does not.
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Error("changing the password logged out the session that did it")
}
ts.do(t, "POST", "/api/v1/auth/logout", nil)
resp, _ = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusOK {
t.Error("the old password still works")
}
}
func TestPasswordRulesRejectShortOnesOnly(t *testing.T) {
ts := newTestServer(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "k@example.dk", "name": "K", "password": "kort",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("short password: status %d", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields["password"] == nil {
t.Errorf("the error does not name the password field: %v", body)
}
// A long passphrase with no digits or symbols must be accepted: composition
// rules push people towards worse passwords, not better ones.
resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{
"email": "k@example.dk", "name": "K", "password": "syv heste løber over marken",
})
if resp.StatusCode != http.StatusCreated {
t.Errorf("a long passphrase was rejected: status %d", resp.StatusCode)
}
}
// Asking to reset a password must not reveal whether the address has an account.
func TestForgotPasswordAnswersTheSameEitherWay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
respKnown, bodyKnown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
"email": "kristian@example.dk",
})
respUnknown, bodyUnknown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{
"email": "findes-ikke@example.dk",
})
if respKnown.StatusCode != respUnknown.StatusCode {
t.Errorf("status differs: %d vs %d", respKnown.StatusCode, respUnknown.StatusCode)
}
if bodyKnown["status"] != bodyUnknown["status"] {
t.Errorf("message differs: %v vs %v", bodyKnown, bodyUnknown)
}
}
func TestPasswordResetEndsEverySession(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token, err := ts.db.CreatePasswordReset(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
resp, _ := ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
"token": token, "password": "et helt nyt kodeord",
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reset: status %d", resp.StatusCode)
}
// Everything is signed out — a reset is what you do when you think somebody
// else has been in your account.
resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a session survived a password reset: %d", resp.StatusCode)
}
// The link works exactly once.
resp, _ = ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{
"token": token, "password": "endnu et kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a reset link worked twice: %d", resp.StatusCode)
}
}
func TestSignupRequiresAnInvite(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, _ := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": "opfundet", "name": "Ubuden", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("signup without a valid invite: status %d, want 400", resp.StatusCode)
}
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token, _, err := ts.db.CreateInvite(t.Context(), "ny@example.dk", "", store.RoleEditor, admin.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
// The address comes from the invite, not from the form — so an invite cannot
// be redirected to somebody else by editing the request.
resp, body := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": token, "name": "Ny Bruger", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("signup: %d %v", resp.StatusCode, body)
}
user, _ := body["user"].(map[string]any)
if user["email"] != "ny@example.dk" {
t.Errorf("email = %v, want the invited address", user["email"])
}
if user["is_admin"] != false {
t.Error("an invited user was made an admin")
}
// And the invite is spent.
resp, _ = ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{
"token": token, "name": "Endnu En", "password": "et langt kodeord",
})
if resp.StatusCode == http.StatusCreated {
t.Error("an invite link was used twice")
}
}
func TestUnauthenticatedRequestsAreRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
for _, tc := range []struct{ method, path string }{
{"GET", "/api/v1/auth/me"},
{"POST", "/api/v1/auth/totp/setup"},
{"POST", "/api/v1/auth/password/change"},
{"GET", "/api/v1/recovery-codes"},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp, _ := ts.do(t, tc.method, tc.path, map[string]string{})
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 401", resp.StatusCode)
}
})
}
}
// A cross-site POST must not be able to act on somebody's session.
func TestCrossSiteWritesAreRefused(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/logout", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("cross-site POST: status %d, want 403", resp.StatusCode)
}
// And the session survived it.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Error("the cross-site request logged the user out anyway")
}
}
func TestMalformedRequestBodies(t *testing.T) {
ts := newTestServer(t)
cases := []struct {
name, body string
contentType string
wantStatus int
}{
{"empty", "", "application/json", http.StatusBadRequest},
{"not json", "hejsa", "application/json", http.StatusBadRequest},
{"wrong field type", `{"email": 42}`, "application/json", http.StatusBadRequest},
{"unknown field", `{"email":"a@b.dk","sneaky":1}`, "application/json", http.StatusBadRequest},
{"two objects", `{"email":"a@b.dk"}{"email":"c@d.dk"}`, "application/json", http.StatusBadRequest},
{"wrong content type", `{"email":"a@b.dk"}`, "text/plain", http.StatusUnsupportedMediaType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/login", bytes.NewReader([]byte(tc.body)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", tc.contentType)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.wantStatus {
t.Errorf("status %d, want %d", resp.StatusCode, tc.wantStatus)
}
})
}
}
// Repeated password guesses must stop being answered.
func TestLoginIsRateLimited(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
var limited bool
for i := 0; i < 30; i++ {
resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "gæt",
})
if resp.StatusCode == http.StatusTooManyRequests {
if resp.Header.Get("Retry-After") == "" {
t.Error("a rate-limited response does not say when to retry")
}
limited = true
break
}
}
if !limited {
t.Error("thirty wrong passwords in a row were all answered")
}
}
func codeFor(t *testing.T, secret string) string {
t.Helper()
code, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
t.Fatalf("generate code: %v", err)
}
return code
}
// newJarClient gives a test its own cookie jar, which is what makes two clients
// against one server two different browsers rather than the same session twice.
func newJarClient(t *testing.T) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
return &http.Client{Jar: jar, Timeout: 10 * time.Second}
}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := auth.HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
return hash
}
// A database that cannot be read must not look like a failed login.
//
// authenticate() cannot tell the two apart on its own: both come back as an error
// from the session lookup. Answering 401 for both means a disk problem presents as
// every session ending at once — the frontend clears its state and shows the
// sign-in screen to everybody — and the logs record a wave of failed logins rather
// than the fault. Found by the end-to-end suite, where a deleted database file
// turned into exactly that.
func TestDatabaseFailureIsNotALogout(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Still signed in, and the session cookie is in the jar.
resp, _ := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("me: status %d", resp.StatusCode)
}
// Pull the database out from under the server. Every query now fails with
// something that is not "no such session".
if err := ts.db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode == http.StatusUnauthorized {
t.Fatal("a broken database signed the user out instead of reporting a fault")
}
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("status %d, want 500", resp.StatusCode)
}
if body["code"] != CodeInternal {
t.Errorf("code = %v, want %q", body["code"], CodeInternal)
}
}
// The other half of the same contract: no cookie at all is still a plain 401.
func TestMissingCredentialsAreStillUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anonymous := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := anonymous.do(t, "GET", "/api/v1/auth/me", nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
if body["code"] != CodeUnauthorized {
t.Errorf("code = %v, want %q", body["code"], CodeUnauthorized)
}
}
// And a cookie that is not a session is a 401 too, not a 500 — an unknown token is
// a wrong credential, which is the client's problem and not the server's.
func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "verdande_session", Value: "ikke-en-rigtig-token"})
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}