import { INestApplication } from "@nestjs/common"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens, } from "./setup.js"; /** * Protects the session/refresh-token invariants of ADR-0003 * (docs/adr/0003-auth-sessions.md) and apps/api/src/auth/auth.service.ts. * * Fixture: one throwaway user (role_key='user', a pre-existing system * role — no new role is created here), invited via the real * POST /v1/users/invite flow as super_admin. Every session created for it * is a byproduct of real login/refresh/logout calls, never a hand-inserted * row — replay detection specifically must be proven end-to-end through * the actual rotation chain, not simulated. * * Cleanup: the fixture user is deactivated (PATCH isActive:false) in * afterAll, which also revokes any sessions still active — there is no * DELETE /v1/users endpoint. Session rows themselves are left in place * (there's no session-deletion endpoint either, and they're an expected * append-only-ish audit trail of real auth activity, not "dirty" state); * they all point at a deactivated, obviously-tagged (qa-test-) fixture * user. */ describe("auth — session lifecycle invariants", () => { let app: INestApplication; let admin: AuthedTokens; let fixtureEmail: string; let fixtureUserId: string; let fixturePassword: string; beforeAll(async () => { app = await createTestApp(); admin = await loginSuperAdmin(app); fixtureEmail = qaEmail("auth"); const invite = await agent(app) .post("/api/v1/users/invite") .set(...bearer(admin.accessToken)) .send({ email: fixtureEmail, roleKey: "user", applications: ["core"] }) .expect(201); fixtureUserId = invite.body.user.id; fixturePassword = invite.body.tempPassword; }); afterAll(async () => { await agent(app) .patch(`/api/v1/users/${fixtureUserId}`) .set(...bearer(admin.accessToken)) .send({ isActive: false }) .expect(200); await closeTestApp(app); }); describe("login — anti-enumeration (eden.md §4.1)", () => { it("wrong password for an existing account returns a generic 401", async () => { const res = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: "definitely-the-wrong-password", applicationKey: "core" }) .expect(401); expect(res.body.message).toBe("Credenciais inválidas"); }); it("a non-existent account returns the exact same 401 message as a wrong password", async () => { const res = await agent(app) .post("/api/v1/auth/login") .send({ email: qaEmail("nobody"), password: "whatever-12345", applicationKey: "core" }) .expect(401); // Invariant: the response must not let a caller distinguish // "account doesn't exist" from "account exists, wrong password" — // if this message ever diverges from the wrong-password case above, // the API becomes an account-enumeration oracle. expect(res.body.message).toBe("Credenciais inválidas"); }); }); describe("refresh — rotation (ADR-0003)", () => { it("a successful refresh returns a different refresh token than the one submitted", async () => { const session = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); const originalRefreshToken = session.body.refreshToken as string; const refreshed = await agent(app) .post("/api/v1/auth/refresh") .send({ refreshToken: originalRefreshToken }) .expect(200); expect(refreshed.body.refreshToken).toBeTypeOf("string"); expect(refreshed.body.refreshToken).not.toBe(originalRefreshToken); }); }); describe("refresh — replay detection revokes the whole session chain (ADR-0003)", () => { it("reusing an already-rotated refresh token is rejected AND invalidates the token that replaced it", async () => { const session = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); const firstToken = session.body.refreshToken as string; const rotated = await agent(app) .post("/api/v1/auth/refresh") .send({ refreshToken: firstToken }) .expect(200); const secondToken = rotated.body.refreshToken as string; expect(secondToken).not.toBe(firstToken); // Replay: reuse the now-rotated-away first token. await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: firstToken }).expect(401); // The specific invariant under test: replay of a revoked token must // revoke the ENTIRE session chain defensively (auth.service.ts // refresh() -> revokeAllUserSessions), not just reject the replayed // token. So the token that legitimately replaced it must now also // be dead, even though it was never itself reused or leaked. await agent(app).post("/api/v1/auth/refresh").send({ refreshToken: secondToken }).expect(401); }); }); describe("logout — idempotent", () => { it("calling logout twice with the same refresh token succeeds both times", async () => { const session = await agent(app) .post("/api/v1/auth/login") .send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" }) .expect(200); const refreshToken = session.body.refreshToken as string; await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204); // Second call: session is already revoked/gone — auth.service.ts's // logout() treats "no active session found" as success, not 404/500. await agent(app).post("/api/v1/auth/logout").send({ refreshToken }).expect(204); }); }); });