package httpserver_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/krow/krow-backend/go-api/internal/auth" "github.com/krow/krow-backend/go-api/internal/httpserver" "github.com/krow/krow-backend/go-api/internal/testutil" ) // shortSessions keeps the expiry arithmetic in these tests small. The real // lifetimes are asserted against the production policy in // TestSessionLifetimes, which is the test that would catch a change to them. var shortSessions = auth.Policy{ IdleLifetime: time.Hour, AbsoluteLifetime: 3 * time.Hour, RememberIdleLifetime: 24 * time.Hour, RememberAbsoluteLifetime: 72 * time.Hour, } // clockedAPI is newAPI with a clock the test drives, so expiry can be reached // without sleeping. func clockedAPI(t *testing.T, opts ...httpserver.Option) (*api, *time.Time) { t.Helper() now := time.Date(2026, 8, 22, 9, 0, 0, 0, time.UTC) all := append([]httpserver.Option{ httpserver.WithClock(func() time.Time { return now }), httpserver.WithSessionPolicy(shortSessions), }, opts...) return newAPI(t, all...), &now } /* ── 1-5. Login and its failure modes ───────────────────────────────────── */ // 1. A correct email and password sign in and return the user. func TestLoginSucceeds(t *testing.T) { a := newAPI(t) result := signIn(t, a.handler, a.email, harnessPassword, false) if result.code != http.StatusOK { t.Fatalf("login = %d, want 200 (%v)", result.code, result.body) } data, ok := result.body["data"].(map[string]any) if !ok { t.Fatalf("login body has no data object: %v", result.body) } if data["id"] != a.userID { t.Errorf("login returned user %v, want %v", data["id"], a.userID) } if data["email"] != a.email { t.Errorf("login returned email %v, want %v", data["email"], a.email) } // The frontend renders the signed-in state straight from this body, so the // embedded preferences must be there as they are on GET /me. if _, ok := data["preferences"].(map[string]any); !ok { t.Error("login response has no embedded preferences") } // The email is case-insensitive: users type their address how they like. upper := signIn(t, a.handler, strings.ToUpper(a.email), harnessPassword, false) if upper.code != http.StatusOK { t.Errorf("login with an upper-case email = %d, want 200", upper.code) } // last_login_at is stamped. var lastLogin *time.Time if err := a.h.Pool.QueryRow(context.Background(), `SELECT last_login_at FROM users WHERE id = $1::uuid`, a.userID).Scan(&lastLogin); err != nil { t.Fatalf("read last_login_at: %v", err) } if lastLogin == nil { t.Error("a successful login did not record last_login_at") } } // 2, 3, 4, 5. Every credential failure is externally identical. // // This is one test rather than four because the property under test is the // sameness: a wrong password, an unknown address, an account with no password // and a suspended account must be indistinguishable from outside. Asserting // each in isolation would not catch the one thing that matters. func TestLoginFailuresAreIndistinguishable(t *testing.T) { a := newAPI(t) // A second account, suspended, with a valid password set. suspended := newUser(t, a.h.Pool, a.orgID, "suspended@example.test", "employer") setStatus(t, a.h.Pool, suspended, "suspended") // A third with no password at all — the state every seeded user starts in. passwordless := "nopassword@example.test" if _, err := a.h.Pool.Exec(context.Background(), `INSERT INTO users (org_id, email, full_name) VALUES ($1::uuid, $2::citext, 'No Password')`, a.orgID, passwordless); err != nil { t.Fatalf("create the passwordless user: %v", err) } cases := map[string]struct{ email, password string }{ "wrong password": {a.email, "definitely-not-the-password"}, "unknown email": {"nobody@example.invalid", harnessPassword}, "suspended user": {"suspended@example.test", harnessPassword}, "no password set": {passwordless, harnessPassword}, "empty-ish password": {a.email, "x"}, } var bodies []string for name, tc := range cases { t.Run(name, func(t *testing.T) { result := signIn(t, a.handler, tc.email, tc.password, false) if result.code != http.StatusUnauthorized { t.Fatalf("login = %d, want 401", result.code) } if result.cookie != nil && result.cookie.Value != "" { t.Error("a failed login set a session cookie") } bodies = append(bodies, result.raw.Body.String()) // The message must not name the reason. body := strings.ToLower(result.raw.Body.String()) for _, leak := range []string{ "password", "suspend", "inactive", "not found", "no such", "unknown", "exist", "email address is not", } { if strings.Contains(body, leak) { t.Errorf("the login error mentions %q, which distinguishes the failure:\n%s", leak, result.raw.Body.String()) } } }) } // 5. Byte-for-byte identical, not merely "all 401". for i := 1; i < len(bodies); i++ { if bodies[i] != bodies[0] { t.Errorf("login failures differ:\n%s\nvs\n%s", bodies[0], bodies[i]) } } // A suspended user with the RIGHT password is still refused. Worth its own // assertion: this is the check that must come after the password test, or // the timing of the refusal leaks that the account exists. if got := signIn(t, a.handler, "suspended@example.test", harnessPassword, false); got.code != http.StatusUnauthorized { t.Errorf("a suspended user with a correct password got %d, want 401", got.code) } } // A malformed request is a 422 about the request, not a 401 about an account. // Saying "you did not send a password" reveals nothing about any user. func TestLoginRejectsMalformedRequests(t *testing.T) { a := newAPI(t) for name, payload := range map[string]any{ "no email": map[string]any{"password": harnessPassword}, "no password": map[string]any{"email": a.email}, "both blank": map[string]any{"email": " ", "password": ""}, } { t.Run(name, func(t *testing.T) { r := a.doAnon("POST", "/api/v1/auth/login", payload) if r.code != http.StatusUnprocessableEntity { t.Errorf("login = %d, want 422", r.code) } }) } } /* ── 6-9. The session cookie ────────────────────────────────────────────── */ // 6, 7. The cookie is created with the right attributes, and the token appears // nowhere a script could read it. func TestLoginSetsHardenedCookieAndNeverReturnsTheToken(t *testing.T) { a := newAPI(t) result := signIn(t, a.handler, a.email, harnessPassword, false) c := result.cookie if c == nil { t.Fatal("login set no session cookie") } if c.Value == "" { t.Fatal("the session cookie is empty") } if !c.HttpOnly { t.Error("the session cookie is not HttpOnly: a script on the page could read it") } if c.SameSite != http.SameSiteLaxMode { t.Errorf("SameSite = %v, want Lax", c.SameSite) } if c.Path != "/" { t.Errorf("Path = %q, want /", c.Path) } // APP_ENV=development in this harness, and localhost is plain HTTP: a // Secure cookie would never be sent back. TestCookieIsSecureOutsideDevelopment // covers the other half. if c.Secure { t.Error("the cookie is Secure in development; the browser would never return it over HTTP") } // 7. The raw token is in the Set-Cookie header and nowhere else. if strings.Contains(result.raw.Body.String(), c.Value) { t.Error("the login response body contains the session token") } // And the same for every other response the API gives while signed in. me := a.doWith(c, "GET", "/api/v1/me") encoded, _ := json.Marshal(me.body) if strings.Contains(string(encoded), c.Value) { t.Error("GET /me leaks the session token") } // 11 (verification list). The database holds the hash, never the token. var rawRows, hashRows int if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, c.Value).Scan(&rawRows); err != nil { t.Fatalf("scan for a stored raw token: %v", err) } if rawRows != 0 { t.Error("the raw session token is stored in PostgreSQL") } if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, auth.HashToken(c.Value)).Scan(&hashRows); err != nil { t.Fatalf("scan for the stored hash: %v", err) } if hashRows != 1 { t.Errorf("%d session rows hold the token's hash, want 1", hashRows) } } // Outside development the cookie must be Secure, or it can be read off the wire. func TestCookieIsSecureOutsideDevelopment(t *testing.T) { h := testutil.New(t) srv := newServer(t, h, nil) userID, email := seededUser(t, h.Pool) setPassword(t, h.Pool, userID) // A production-shaped server over the same database. prod := newServerWithEnv(t, h, "production") if got := signIn(t, prod, email, harnessPassword, false); got.cookie == nil || !got.cookie.Secure { t.Errorf("the cookie is not Secure when APP_ENV=production: %+v", got.cookie) } // The development server, for contrast, on the same database. if got := signIn(t, srv.Handler(), email, harnessPassword, false); got.cookie == nil || got.cookie.Secure { t.Error("the cookie is Secure in development") } } // 8, 9. Normal and Remember Me sessions get the lifetimes the decision names, // in the cookie and in the row. func TestSessionLifetimes(t *testing.T) { a := newAPI(t) // the production policy: 12h / 24h and 30d / 90d for name, tc := range map[string]struct { remember bool wantIdle time.Duration wantAbsolute time.Duration }{ "normal": {false, 12 * time.Hour, 24 * time.Hour}, "remember me": {true, 30 * 24 * time.Hour, 90 * 24 * time.Hour}, } { t.Run(name, func(t *testing.T) { before := time.Now() result := signIn(t, a.handler, a.email, harnessPassword, tc.remember) if result.code != http.StatusOK || result.cookie == nil { t.Fatalf("login = %d", result.code) } // The cookie's own lifetime matches the session's. wantMaxAge := int(tc.wantIdle.Seconds()) if drift := result.cookie.MaxAge - wantMaxAge; drift > 5 || drift < -5 { t.Errorf("cookie Max-Age = %d, want about %d", result.cookie.MaxAge, wantMaxAge) } // And so does the row, which is the authority. var expires, absolute time.Time if err := a.h.Pool.QueryRow(context.Background(), `SELECT expires_at, absolute_expires_at FROM sessions WHERE token_hash = $1::text`, auth.HashToken(result.cookie.Value)).Scan(&expires, &absolute); err != nil { t.Fatalf("read the session row: %v", err) } assertAbout(t, "expires_at", expires.Sub(before), tc.wantIdle) assertAbout(t, "absolute_expires_at", absolute.Sub(before), tc.wantAbsolute) if absolute.Before(expires) { t.Error("the absolute deadline is before the sliding one") } }) } } func assertAbout(t *testing.T, name string, got, want time.Duration) { t.Helper() if drift := got - want; drift > time.Minute || drift < -time.Minute { t.Errorf("%s is %v from now, want about %v", name, got, want) } } /* ── 10-11. Logout ──────────────────────────────────────────────────────── */ // 10, 11. Logging out revokes the session, clears the cookie, and is idempotent. func TestLogout(t *testing.T) { a := newAPI(t) // Signed in, the protected endpoint works. if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { t.Fatalf("GET /me before logout = %d, want 200", got.code) } req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil) req.AddCookie(a.cookie) rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("logout = %d, want 200 (%s)", rec.Code, rec.Body.String()) } // The cookie is expired in the browser. var cleared *http.Cookie for _, c := range rec.Result().Cookies() { if c.Name == sessionCookie { cleared = c } } if cleared == nil { t.Fatal("logout did not clear the session cookie") } if cleared.MaxAge >= 0 || cleared.Value != "" { t.Errorf("the cleared cookie is %+v, want an empty value and a negative Max-Age", cleared) } // The attributes must match the ones it was set with, or the browser keeps // the original alongside this one. if cleared.Path != "/" || !cleared.HttpOnly { t.Errorf("the cleared cookie has different attributes: %+v", cleared) } // The row is gone, not merely expired. var rows int if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, auth.HashToken(a.cookie.Value)).Scan(&rows); err != nil { t.Fatalf("count sessions: %v", err) } if rows != 0 { t.Error("logout left the session row in the database") } // The old cookie no longer authenticates anything. if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { t.Errorf("GET /me after logout = %d, want 401", got.code) } } // 11. Every shape of logout succeeds: twice over, with a stale cookie, and with // no cookie at all. A user asking not to be signed in is not signed in // afterwards in all three cases, so all three are successes. func TestLogoutIsIdempotent(t *testing.T) { a := newAPI(t) stale := a.cookie for i, attempt := range []string{"first", "second", "third"} { req := httptest.NewRequest("POST", "/api/v1/auth/logout", nil) req.AddCookie(stale) rec := httptest.NewRecorder() a.handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("%s logout (attempt %d) = %d, want 200", attempt, i+1, rec.Code) } } // With no cookie at all — a signed-out browser clicking sign out. if got := a.doAnon("POST", "/api/v1/auth/logout", nil); got.code != http.StatusOK { t.Errorf("logout with no cookie = %d, want 200", got.code) } // And with a token that was never real. junk := &http.Cookie{Name: sessionCookie, Value: "not-a-real-token"} if got := a.doWith(junk, "POST", "/api/v1/auth/logout"); got.code != http.StatusOK { t.Errorf("logout with a junk cookie = %d, want 200", got.code) } } /* ── 12-15, 20. The middleware ──────────────────────────────────────────── */ // 20. Without a session, protected endpoints refuse. func TestUnauthenticatedRequestsAreRefused(t *testing.T) { a := newAPI(t) for _, path := range []string{ "/api/v1/me", "/api/v1/me/preferences", "/api/v1/job-postings", "/api/v1/job-applications", "/api/v1/worker-profiles", "/api/v1/courses", "/api/v1/staff", } { got := a.doAnon("GET", path, nil) if got.code != http.StatusUnauthorized { t.Errorf("GET %s without a session = %d, want 401", path, got.code) } if body, _ := got.body["error"].(map[string]any); body == nil || body["code"] != "unauthorized" { t.Errorf("GET %s: error code = %v, want unauthorized", path, got.body) } } // Writes too, not only reads. if got := a.doAnon("POST", "/api/v1/job-postings", map[string]any{"title": "x"}); got.code != http.StatusUnauthorized { t.Errorf("POST without a session = %d, want 401", got.code) } if got := a.doAnon("PATCH", "/api/v1/me", map[string]any{"full_name": "x"}); got.code != http.StatusUnauthorized { t.Errorf("PATCH /me without a session = %d, want 401", got.code) } // The public three stay public. if got := a.doAnon("GET", "/health", nil); got.code != http.StatusOK { t.Errorf("GET /health without a session = %d, want 200", got.code) } if got := a.doAnon("POST", "/api/v1/auth/logout", nil); got.code != http.StatusOK { t.Errorf("POST /auth/logout without a session = %d, want 200", got.code) } if got := a.doAnon("POST", "/api/v1/auth/login", map[string]any{ "email": a.email, "password": harnessPassword, }); got.code != http.StatusOK { t.Errorf("POST /auth/login without a session = %d, want 200", got.code) } } // 12. A token that does not name a session is refused, whatever it looks like. func TestInvalidSessionsAreRejected(t *testing.T) { a := newAPI(t) unknown, err := auth.GenerateToken() if err != nil { t.Fatalf("GenerateToken: %v", err) } for name, value := range map[string]string{ "well-formed but unknown": unknown, "junk": "not-a-token-at-all", "empty": "", "the stored hash": auth.HashToken(a.cookie.Value), "the token, altered": a.cookie.Value[:len(a.cookie.Value)-1] + "X", } { t.Run(name, func(t *testing.T) { got := a.doWith(&http.Cookie{Name: sessionCookie, Value: value}, "GET", "/api/v1/me") if got.code != http.StatusUnauthorized { t.Errorf("GET /me with a %s token = %d, want 401", name, got.code) } }) } // Presenting the *hash* must not work. It is the value in the database, so // a lookup that forgot to hash the cookie would accept it — and a leaked // database dump would then be a set of working credentials. got := a.doWith(&http.Cookie{Name: sessionCookie, Value: auth.HashToken(a.cookie.Value)}, "GET", "/api/v1/me") if got.code == http.StatusOK { t.Fatal("the stored token hash authenticated as a token") } } // 13. An expired session is refused, and the row is cleaned up as it is found. func TestExpiredSessionIsRejected(t *testing.T) { a, now := clockedAPI(t) if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { t.Fatalf("GET /me while live = %d, want 200", got.code) } // Past the idle deadline without using it. *now = now.Add(shortSessions.IdleLifetime + time.Minute) if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { t.Fatalf("GET /me after expiry = %d, want 401", got.code) } var rows int if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions WHERE token_hash = $1::text`, auth.HashToken(a.cookie.Value)).Scan(&rows); err != nil { t.Fatalf("count sessions: %v", err) } if rows != 0 { t.Error("an expired session was refused but left in the database") } } // A session used steadily slides forward and keeps working — but never past its // absolute ceiling. func TestSessionSlidesButNotForever(t *testing.T) { a, now := clockedAPI(t) start := *now for _, at := range []time.Duration{50 * time.Minute, 105 * time.Minute, 160 * time.Minute} { *now = start.Add(at) if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusOK { t.Fatalf("GET /me at +%v = %d, want 200 — the session should have slid", at, got.code) } } *now = start.Add(shortSessions.AbsoluteLifetime) if got := a.do("GET", "/api/v1/me", nil); got.code != http.StatusUnauthorized { t.Errorf("GET /me at the absolute ceiling = %d, want 401", got.code) } } // 14. The identity is the session's user, not the first or the oldest one. func TestAuthenticatedRequestResolvesTheSessionUser(t *testing.T) { a := newAPI(t) other := newUser(t, a.h.Pool, a.orgID, "second-user@example.test", "employer") result := signIn(t, a.handler, "second-user@example.test", harnessPassword, false) if result.code != http.StatusOK { t.Fatalf("second user login = %d", result.code) } got := a.doWith(result.cookie, "GET", "/api/v1/me") if got.code != http.StatusOK { t.Fatalf("GET /me = %d", got.code) } data := got.body["data"].(map[string]any) if data["id"] != other { t.Errorf("GET /me returned %v, want the second user %v", data["id"], other) } if data["email"] != "second-user@example.test" { t.Errorf("GET /me returned email %v", data["email"]) } // The seeded user's own cookie still resolves to the seeded user: two // sessions, two identities, no crosstalk. first := a.do("GET", "/api/v1/me", nil) if first.body["data"].(map[string]any)["id"] != a.userID { t.Error("the first session no longer resolves to its own user") } } // 15. Suspending an account takes effect on its next request, and takes the // session with it. func TestSuspendedUserIsRejectedMidSession(t *testing.T) { a := newAPI(t) victim := newUser(t, a.h.Pool, a.orgID, "about-to-be-suspended@example.test", "employer") result := signIn(t, a.handler, "about-to-be-suspended@example.test", harnessPassword, false) if result.code != http.StatusOK { t.Fatalf("login = %d", result.code) } if got := a.doWith(result.cookie, "GET", "/api/v1/me"); got.code != http.StatusOK { t.Fatalf("GET /me while active = %d, want 200", got.code) } setStatus(t, a.h.Pool, victim, "suspended") if got := a.doWith(result.cookie, "GET", "/api/v1/me"); got.code != http.StatusUnauthorized { t.Fatalf("GET /me after suspension = %d, want 401", got.code) } // The session is destroyed rather than refused one request at a time: a // suspended account must not keep a working cookie for thirty days. var rows int if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions WHERE user_id = $1::uuid`, victim).Scan(&rows); err != nil { t.Fatalf("count sessions: %v", err) } if rows != 0 { t.Errorf("%d sessions survive for a suspended user, want 0", rows) } } /* ── 16-19. /me ─────────────────────────────────────────────────────────── */ // 16. GET /me is the session's user. func TestMeReturnsTheSessionUser(t *testing.T) { a := newAPI(t) got := a.do("GET", "/api/v1/me", nil) if got.code != http.StatusOK { t.Fatalf("GET /me = %d", got.code) } data := got.body["data"].(map[string]any) if data["id"] != a.userID { t.Errorf("GET /me id = %v, want %v", data["id"], a.userID) } // The frontend contract: these fields must still be here. for _, field := range []string{"id", "email", "full_name", "role", "account_type", "status", "preferences"} { if _, ok := data[field]; !ok { t.Errorf("GET /me no longer returns %q", field) } } // And these must not be. for _, field := range []string{"password_hash", "org_id"} { if _, ok := data[field]; ok { t.Errorf("GET /me exposes %q", field) } } } // 17, 18, 19. A user cannot edit what the server owns about them. func TestMeCannotEditServerOwnedFields(t *testing.T) { a := newAPI(t) before := readUserRow(t, a) // Everything at once, plus each on its own below, because a handler could // plausibly filter one and not another. got := a.do("PATCH", "/api/v1/me", map[string]any{ "role": "admin", "org_id": "00000000-0000-0000-0000-000000000000", "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$YWFhYWFhYWFhYWFhYWFhYQ$" + strings.Repeat("A", 43), "id": "00000000-0000-0000-0000-000000000000", "status": "suspended", "email": "attacker@example.invalid", "full_name": "A Legitimate Rename", }) if got.code != http.StatusOK { t.Fatalf("PATCH /me = %d (%v)", got.code, got.body) } after := readUserRow(t, a) if after.role != before.role { t.Errorf("role changed from %q to %q — privilege escalation", before.role, after.role) } if after.orgID != before.orgID { t.Errorf("org_id changed from %q to %q — tenancy escape", before.orgID, after.orgID) } if after.passwordHash != before.passwordHash { t.Error("password_hash was overwritten through PATCH /me") } if after.id != before.id { t.Errorf("id changed from %q to %q", before.id, after.id) } if after.status != before.status { t.Errorf("status changed from %q to %q", before.status, after.status) } if after.email != before.email { t.Errorf("email changed from %q to %q", before.email, after.email) } // The one legitimate field in that payload did land, so the endpoint is // filtering rather than refusing everything. if after.fullName != "A Legitimate Rename" { t.Errorf("full_name = %q, want the rename to have applied", after.fullName) } // The response reports the truth rather than echoing the request. if data := got.body["data"].(map[string]any); data["role"] != before.role { t.Errorf("the response reports role %v, want the unchanged %q", data["role"], before.role) } // One at a time. for field, value := range map[string]any{ "role": "admin", "org_id": "00000000-0000-0000-0000-000000000000", "password_hash": "anything", "status": "suspended", } { t.Run(field, func(t *testing.T) { r := a.do("PATCH", "/api/v1/me", map[string]any{field: value}) if r.code != http.StatusOK { t.Fatalf("PATCH /me {%s} = %d", field, r.code) } now := readUserRow(t, a) if now.role != before.role || now.orgID != before.orgID || now.passwordHash != before.passwordHash || now.status != before.status { t.Errorf("PATCH /me {%s: %v} changed a server-owned field", field, value) } }) } // account_type stays editable: it is a display attribute, not authorization, // and Layout.jsx writes it when the viewer switches surface. if r := a.do("PATCH", "/api/v1/me", map[string]any{"account_type": "talent"}); r.code != http.StatusOK { t.Fatalf("PATCH /me {account_type} = %d", r.code) } if readUserRow(t, a).accountType != "talent" { t.Error("account_type is no longer self-editable; Layout.jsx's role switch depends on it") } } type userRow struct { id, orgID, email, fullName, role, accountType, status, passwordHash string } func readUserRow(t *testing.T, a *api) userRow { t.Helper() var u userRow if err := a.h.Pool.QueryRow(context.Background(), `SELECT id::text, org_id::text, email::text, full_name, role, account_type, status, COALESCE(password_hash, '') FROM users WHERE id = $1::uuid`, a.userID). Scan(&u.id, &u.orgID, &u.email, &u.fullName, &u.role, &u.accountType, &u.status, &u.passwordHash); err != nil { t.Fatalf("read the user row: %v", err) } return u } /* ── Identity comes from the session, never from the request ────────────── */ // The security property the whole phase exists for: nothing a client writes can // change who it is. func TestIdentityCannotBeSuppliedByTheRequest(t *testing.T) { a := newAPI(t) victim := newUser(t, a.h.Pool, a.orgID, "victim@example.test", "admin") // A body naming another user. got := a.do("PATCH", "/api/v1/me", map[string]any{ "id": victim, "user_id": victim, "full_name": "Renamed By An Impostor", }) if got.code != http.StatusOK { t.Fatalf("PATCH /me = %d", got.code) } if got.body["data"].(map[string]any)["id"] != a.userID { t.Error("a user id in the body changed whose record was returned") } var victimName string if err := a.h.Pool.QueryRow(context.Background(), `SELECT full_name FROM users WHERE id = $1::uuid`, victim).Scan(&victimName); err != nil { t.Fatalf("read the victim: %v", err) } if victimName == "Renamed By An Impostor" { t.Fatal("a user id in the request body redirected the write to another user") } // A query string naming another user, and another organization. for _, q := range []string{ "?user_id=" + victim, "?org_id=00000000-0000-0000-0000-000000000000", "?id=" + victim, } { r := a.do("GET", "/api/v1/me"+q, nil) if r.code != http.StatusOK { t.Fatalf("GET /me%s = %d", q, r.code) } if r.body["data"].(map[string]any)["id"] != a.userID { t.Errorf("GET /me%s resolved to a different user", q) } } } // The organization is read from the user's row, so a session in one tenant sees // no data from another. This is what replaced the fixed development org. func TestTenancyFollowsTheSession(t *testing.T) { a := newAPI(t) // The seeded organization has data. mine := a.do("GET", "/api/v1/job-postings?limit=100", nil) if mine.code != http.StatusOK { t.Fatalf("GET /job-postings = %d", mine.code) } if len(mine.records(t)) == 0 { t.Fatal("the seeded organization has no job postings; the test proves nothing") } // A second organization, with a user of its own and no data. var otherOrg string if err := a.h.Pool.QueryRow(context.Background(), `INSERT INTO organizations (name, slug) VALUES ('Other Tenant', 'other-tenant') RETURNING id::text`). Scan(&otherOrg); err != nil { t.Fatalf("create the second organization: %v", err) } newUser(t, a.h.Pool, otherOrg, "outsider@example.test", "admin") result := signIn(t, a.handler, "outsider@example.test", harnessPassword, false) if result.code != http.StatusOK { t.Fatalf("outsider login = %d", result.code) } theirs := a.doWith(result.cookie, "GET", "/api/v1/job-postings?limit=100") if theirs.code != http.StatusOK { t.Fatalf("GET /job-postings as the outsider = %d", theirs.code) } if n := len(theirs.records(t)); n != 0 { t.Errorf("a user in another organization sees %d job postings, want 0", n) } } /* ── 21. Sweeping ───────────────────────────────────────────────────────── */ // 21. The sweep collects sessions nobody comes back for, and leaves live ones. func TestSessionSweep(t *testing.T) { a, now := clockedAPI(t) ctx := context.Background() // a.cookie is a normal session (1h idle here). Add a Remember Me one. long := signIn(t, a.handler, a.email, harnessPassword, true) if long.code != http.StatusOK { t.Fatalf("remember-me login = %d", long.code) } if n := countSessions(t, a); n != 2 { t.Fatalf("%d sessions before the sweep, want 2", n) } // Nothing is due yet. if deleted, err := a.srv.Sessions().Sweep(ctx); err != nil || deleted != 0 { t.Errorf("early sweep deleted %d (err %v), want 0", deleted, err) } // Past the short session's deadline, not the long one's. *now = now.Add(shortSessions.IdleLifetime + time.Minute) deleted, err := a.srv.Sessions().Sweep(ctx) if err != nil { t.Fatalf("Sweep: %v", err) } if deleted != 1 { t.Errorf("sweep deleted %d sessions, want 1", deleted) } if n := countSessions(t, a); n != 1 { t.Errorf("%d sessions remain, want 1", n) } // The survivor still works. if got := a.doWith(long.cookie, "GET", "/api/v1/me"); got.code != http.StatusOK { t.Errorf("the swept database rejected a live session: %d", got.code) } // Past everything. *now = now.Add(shortSessions.RememberAbsoluteLifetime) if _, err := a.srv.Sessions().Sweep(ctx); err != nil { t.Fatalf("Sweep: %v", err) } if n := countSessions(t, a); n != 0 { t.Errorf("%d sessions remain after everything expired, want 0", n) } } func countSessions(t *testing.T, a *api) int { t.Helper() var n int if err := a.h.Pool.QueryRow(context.Background(), `SELECT count(*)::int FROM sessions`).Scan(&n); err != nil { t.Fatalf("count sessions: %v", err) } return n } /* ── 22. Rate limiting ──────────────────────────────────────────────────── */ // 22. Repeated failures are refused, before any password is checked. func TestLoginRateLimit(t *testing.T) { a, now := clockedAPI(t, httpserver.WithLoginRateLimit(3, 50, 10*time.Minute)) start := *now // Three failures use the budget. for i := 1; i <= 3; i++ { if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { t.Fatalf("failure %d = %d, want 401", i, got.code) } } // The fourth is refused as rate limited, not as a bad password. blocked := signIn(t, a.handler, a.email, "wrong-password", false) if blocked.code != http.StatusTooManyRequests { t.Fatalf("the fourth attempt = %d, want 429", blocked.code) } if got := blocked.raw.Header().Get("Retry-After"); got == "" || got == "0" { t.Errorf("Retry-After = %q, want a positive number of seconds", got) } if body, _ := blocked.body["error"].(map[string]any); body == nil || body["code"] != "rate_limited" { t.Errorf("error code = %v, want rate_limited", blocked.body) } // The CORRECT password is refused too. The limit is checked before the // credentials, which is the point: an attacker must not be able to make the // server hash for them, and must not learn from a 401-vs-429 difference // whether their guess was right. correct := signIn(t, a.handler, a.email, harnessPassword, false) if correct.code != http.StatusTooManyRequests { t.Errorf("a correct password during a block = %d, want 429", correct.code) } if correct.cookie != nil { t.Error("a rate-limited login still issued a session") } // The window passes and the budget returns. *now = start.Add(11 * time.Minute) if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusOK { t.Fatalf("login after the window = %d, want 200", got.code) } // A success clears the email's counter, so two typos then a success leaves // nothing behind. for i := 0; i < 2; i++ { if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { t.Fatalf("typo %d = %d, want 401", i+1, got.code) } } if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusOK { t.Fatalf("login after two typos = %d, want 200", got.code) } for i := 0; i < 3; i++ { if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { t.Errorf("after the reset, failure %d = %d, want 401 — the counter did not clear", i+1, got.code) } } } // The limit is per email as well as per address, so one account cannot be // ground down by an attacker who has plenty of addresses — and one address // cannot work through plenty of accounts. func TestLoginRateLimitIsPerEmailAndPerAddress(t *testing.T) { // Three per email, five per address: tight enough to reach both bounds in a // handful of attempts, and shaped like the production pair, where the // address budget is the wider one. a, _ := clockedAPI(t, httpserver.WithLoginRateLimit(3, 5, 10*time.Minute)) newUser(t, a.h.Pool, a.orgID, "unrelated@example.test", "employer") // Exhaust the seeded account's own budget. for i := 0; i < 3; i++ { if got := signIn(t, a.handler, a.email, "wrong-password", false); got.code != http.StatusUnauthorized { t.Fatalf("failure %d = %d, want 401", i+1, got.code) } } if got := signIn(t, a.handler, a.email, harnessPassword, false); got.code != http.StatusTooManyRequests { t.Fatalf("the blocked email = %d, want 429", got.code) } // A different account from the same address still works: the per-email // budget is per email, so one account being attacked does not lock out // everyone else behind the same NAT. if got := signIn(t, a.handler, "unrelated@example.test", harnessPassword, false); got.code != http.StatusOK { t.Fatalf("a second email from the same address = %d, want 200", got.code) } // But the address budget is real. Two more failures from here reach five, // and then nothing from this address gets through, whichever account it // names. for i := 0; i < 2; i++ { if got := signIn(t, a.handler, "unrelated@example.test", "wrong-password", false); got.code != http.StatusUnauthorized { t.Fatalf("address failure %d = %d, want 401", i+1, got.code) } } if got := signIn(t, a.handler, "unrelated@example.test", harnessPassword, false); got.code != http.StatusTooManyRequests { t.Errorf("the address budget was not enforced: %d, want 429", got.code) } }