/** * AI Software Factory — Integration: Auth (Phase 1) * * Full auth contract against the real API + Postgres + Redis: * register, login, refresh rotation, reuse detection, rate limiting, * session state, and audit events for security-sensitive operations. * * Env: real Testcontainers Postgres/Redis (helpers/containers.ts); random * JWT/refresh/session secrets generated per run (helpers/api.ts). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createUser, randomPassword, uniqueEmail, countAuditEvents } from "./helpers/seed"; // The refresh token is stored as its SHA-256 hash; the concurrency test needs // to hash the plaintext it received to look the rows up. import { sha256hex } from "@factory/api/src/lib/crypto"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; describe("Auth integration", () => { it("register: creates user, hashes password, marks email unverified", async () => { const email = uniqueEmail("reg"); const password = randomPassword(); const res = await app.request("POST", "/api/v1/auth/register", { body: { email, password, name: "Reg Test" }, }); expect(res.status).toBe(201); const body = await json(res); const user = body.user as { id: string; email: string } | undefined; expect(user?.email).toBe(email); // DB: password is stored hashed (bcrypt — never plaintext). const row = await app.prisma.user.findUnique({ where: { email } }); expect(row).not.toBeNull(); expect(row?.passwordHash).toBeTruthy(); expect(row?.passwordHash).not.toContain(password); expect(row?.passwordHash).toMatch(/^\$2[aby]\$/); // bcrypt prefix expect(row?.emailVerification).toBe("UNVERIFIED"); }); it("register: duplicate email rejected", async () => { const email = uniqueEmail("dup"); await app.request("POST", "/api/v1/auth/register", { body: { email, password: randomPassword(), name: "Dup" }, }); const dup = await app.request("POST", "/api/v1/auth/register", { body: { email, password: randomPassword(), name: "Dup 2" }, }); expect([400, 409]).toContain(dup.status); }); it("login: returns access + refresh tokens; wrong password → 401", async () => { const { email, password } = await createUser(app.request, { name: "Login Test" }); const ok = await app.request("POST", "/api/v1/auth/login", { body: { email, password }, }); expect(ok.status).toBe(200); const body = await json(ok); expect(body.accessToken).toBeTruthy(); expect(body.refreshToken).toBeTruthy(); const bad = await app.request("POST", "/api/v1/auth/login", { body: { email, password: "wrong-password-xyz" }, }); expect(bad.status).toBe(401); }); it("me: requires auth; returns the user when authenticated", async () => { const anon = await app.request("GET", "/api/v1/auth/me"); expect(anon.status).toBe(401); const { accessToken } = await createUser(app.request, { name: "Me Test" }); expect(accessToken).toBeTruthy(); const me = await app.request("GET", "/api/v1/auth/me", { headers: authHeaders(accessToken!), }); expect(me.status).toBe(200); const body = await json(me); expect((body.user as { id: string }).id).toBeTruthy(); }); it("refresh rotation: old token invalidated, new token works", async () => { const user = await createUser(app.request, { name: "Rotation Test" }); const login = await app.request("POST", "/api/v1/auth/login", { body: { email: user.email, password: user.password }, }); const first = (await json(login)).refreshToken as string; // Rotate. const r1 = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first }, }); expect(r1.status).toBe(200); const r1Body = await json(r1); const second = r1Body.refreshToken as string; expect(second).toBeTruthy(); expect(second).not.toBe(first); // The rotated token still grants access (new access token issued). expect(r1Body.accessToken).toBeTruthy(); // DB: the first token row is revoked (or replaced). const firstRow = await app.prisma.refreshToken.findFirst({ where: { userId: user.userId }, orderBy: { createdAt: "desc" }, }); expect(firstRow).not.toBeNull(); const anyRevoked = await app.prisma.refreshToken.count({ where: { userId: user.userId, revokedAt: { not: null } }, }); expect(anyRevoked).toBeGreaterThanOrEqual(1); }); it("refresh stale retry: rotated token with a LIVE successor → 401, no family kill", async () => { const user = await createUser(app.request, { name: "Stale Retry Test" }); const login = await app.request("POST", "/api/v1/auth/login", { body: { email: user.email, password: user.password }, }); const first = (await json(login)).refreshToken as string; // Rotate once (revokes `first`, issues `second`). const r1 = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first }, }); expect(r1.status).toBe(200); const second = (await json(r1)).refreshToken as string; const staleBefore = await countAuditEvents(app.prisma, { action: "auth.refresh_stale_retry" }); // Re-present the already-rotated `first` while its successor `second` is // still LIVE (security-architecture.md §1.2 step 3, case a): benign // stale retry → plain 401 `refresh_token_stale`, NO session loss. const stale = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first }, }); expect(stale.status).toBe(401); const staleBody = await json(stale); const staleReason = (staleBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find( (d) => d.reason )?.reason; expect(staleReason).toBe("refresh_token_stale"); // The presented token is dead, so the httpOnly cookie that carried it // must be cleared (max-age 0 / expired): the web middleware gates on // cookie EXISTENCE only, and a dead-but-present cookie keeps bouncing // /login → /w/default behind a gate that can never authenticate (the // stuck-state livelock from the parallel E2E flake). const setCookie = stale.headers.get("set-cookie") ?? ""; expect(setCookie, "stale 401 must clear the factory_refresh cookie").toMatch(/factory_refresh=;/); // The live successor is UNTOUCHED — the session survived. const after = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: second }, }); expect(after.status).toBe(200); // Audit: recorded as stale retry, NOT as a reuse attack. const staleAfter = await countAuditEvents(app.prisma, { action: "auth.refresh_stale_retry" }); expect(staleAfter).toBe(staleBefore + 1); const reuseCount = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" }); expect(reuseCount).toBe(0); }); it("refresh reuse: rotated token with a DEAD successor → family kill (reuse attack)", async () => { const user = await createUser(app.request, { name: "Reuse Attack Test" }); const login = await app.request("POST", "/api/v1/auth/login", { body: { email: user.email, password: user.password }, }); const first = (await json(login)).refreshToken as string; // Rotate twice: first → second → third. Both `first` and `second` are // now revoked; `third` is the only live token. const r1 = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first }, }); expect(r1.status).toBe(200); const second = (await json(r1)).refreshToken as string; const r2 = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: second }, }); expect(r2.status).toBe(200); const third = (await json(r2)).refreshToken as string; const reuseBefore = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" }); // Re-present `first`: revoked, AND its successor `second` is dead too // (§1.2 step 3, case b) — the chain moved on without this presentation, // so the token must be treated as stolen → reuse attack → family kill. const reuse = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first }, }); expect(reuse.status).toBe(401); const reuseBody = await json(reuse); const reuseReason = (reuseBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find( (d) => d.reason )?.reason; expect(reuseReason).toBe("refresh_token_reused"); // The WHOLE family is dead: even the latest live token `third` no longer // works (token theft ⇒ kill the chain), and the session is revoked. const after = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: third }, }); expect(after.status).toBe(401); // DB: every refresh token of the user is revoked. const liveLeft = await app.prisma.refreshToken.count({ where: { userId: user.userId, revokedAt: null }, }); expect(liveLeft).toBe(0); const session = await app.prisma.session.findFirst({ where: { userId: user.userId }, orderBy: { createdAt: "desc" }, }); expect(session?.status).toBe("REVOKED"); // Audit: a reuse-attack event was recorded. (Presenting `third` above is // itself a post-kill replay — `third` is now dead with no live successor — // so it audits as a SECOND reuse; we only require that detection fired.) const reuseAfter = await countAuditEvents(app.prisma, { action: "auth.refresh_reuse_detected" }); expect(reuseAfter).toBeGreaterThan(reuseBefore); }); it("refresh concurrency: two in-flight rotations of one token → one live successor, no orphan", async () => { const user = await createUser(app.request, { name: "Rotation Race Test" }); const login = await app.request("POST", "/api/v1/auth/login", { body: { email: user.email, password: user.password }, }); const first = (await json(login)).refreshToken as string; // Fire two rotations of the SAME token concurrently (the StrictMode // double-bootstrap shape). Both read `first` as live; exactly one may // win the conditional revocation — the loser must roll its new row back // (stale-retry 401), never leave an orphaned live token behind. const [rA, rB] = await Promise.all([ app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first } }), app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: first } }), ]); const statuses = [rA.status, rB.status].sort(); expect(statuses).toEqual([200, 401]); const loserBody = await json(rA.status === 401 ? rA : rB); const loserReason = (loserBody.error as { details?: { reason?: string }[] } | undefined)?.details?.find( (d) => d.reason )?.reason; expect(loserReason).toBe("refresh_token_stale"); // The winner's successor is the ONLY live token in `first`'s session // chain — a leaked (non-rolled-back) loser rotation would leave a SECOND // live row anchored at the same session (the orphaned-live-token defect). // (The user's OTHER sessions — register + login — keep their own tokens.) const winner = (await json(rA.status === 200 ? rA : rB)).refreshToken as string; const winnerLive = await app.prisma.refreshToken.findUnique({ where: { tokenHash: sha256hex(winner) }, select: { id: true, revokedAt: true, sessionAgent: true, replacedBy: true }, }); expect(winnerLive).not.toBeNull(); expect(winnerLive?.revokedAt).toBeNull(); // `first`'s session must have exactly one live token (the winner's) — a // leaked (non-rolled-back) loser rotation would leave a second. const firstSessionLive = await app.prisma.refreshToken.count({ where: { userId: user.userId, revokedAt: null, sessionAgent: winnerLive!.sessionAgent ?? "" }, }); expect(firstSessionLive).toBe(1); // `first` is revoked and points at the winner (the rotation chain is // intact — reuse detection can still distinguish stale vs attack). const firstRow = await app.prisma.refreshToken.findUnique({ where: { tokenHash: sha256hex(first) }, }); expect(firstRow?.revokedAt).not.toBeNull(); expect(firstRow?.replacedBy).toBe(winnerLive!.id); // The winner's token still authenticates (rotates forward to a third). const winnerNext = await app.request("POST", "/api/v1/auth/refresh", { body: { refreshToken: winner }, }); expect(winnerNext.status).toBe(200); }); it("rate limit: sustained login failures from one client → 429", async () => { const email = uniqueEmail("rl"); await app.request("POST", "/api/v1/auth/register", { body: { email, password: randomPassword(), name: "RL" }, }); let got429 = false; for (let i = 0; i < 20; i++) { const res = await app.request("POST", "/api/v1/auth/login", { body: { email, password: `wrong-${i}` }, }); if (res.status === 429) { got429 = true; break; } } expect(got429, "expected 429 after sustained login failures (RATE_LIMIT_* config)").toBe(true); }); it("audit: login is recorded as an audit event", async () => { const user = await createUser(app.request, { name: "Audit Login Test" }); const before = await countAuditEvents(app.prisma, { action: "user.login" }); await app.request("POST", "/api/v1/auth/login", { body: { email: user.email, password: user.password }, }); const after = await countAuditEvents(app.prisma, { action: "user.login" }); expect(after).toBe(before + 1); }); });