Files
krow_backend/go-api/internal/httpserver/auth_test.go
2026-08-24 13:06:29 +05:30

927 lines
34 KiB
Go

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)
}
}