first commit
This commit is contained in:
926
go-api/internal/httpserver/auth_test.go
Normal file
926
go-api/internal/httpserver/auth_test.go
Normal file
@@ -0,0 +1,926 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user