/** * Auth route edge-case tests — login failures, session expiry, * password-change side effects, register validation. */ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../../apps/api/src/db/index.js"; import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js"; let testApp: TestApp; let adminToken: string; const uid = () => `auth_test_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; beforeAll(async () => { testApp = await buildTestApp(); adminToken = await loginAsAdmin(testApp.app); }, 30_000); afterAll(async () => { await testApp.cleanup(); }, 10_000); // Helper: register a user, clear mustChangePassword, return { username, password } async function createUser( opts: { role?: string; team?: string } = {}, ): Promise<{ username: string; password: string; id: string }> { const username = uid(); const password = "ValidPass1"; const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username, password, ...opts }, }); const body = JSON.parse(res.body); if (res.statusCode !== 201) { throw new Error(`createUser failed: ${res.statusCode} ${res.body}`); } await db .update(schema.users) .set({ mustChangePassword: false }) .where(eq(schema.users.username, username)); return { username, password, id: body.id }; } // Helper: login and return token async function loginAs(username: string, password: string): Promise { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username, password }, }); const body = JSON.parse(res.body); if (!body.token) throw new Error(`loginAs failed: ${res.body}`); return body.token as string; } // ═══════════════════════════════════════════════════════════════════════════ // LOGIN FAILURES // ═══════════════════════════════════════════════════════════════════════════ describe("Login failures", () => { it("empty body returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: {}, }); expect(res.statusCode).toBe(400); }); it("missing username returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { password: "Anything1" }, }); expect(res.statusCode).toBe(400); }); it("missing password returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "admin" }, }); expect(res.statusCode).toBe(400); }); it("unknown username returns 401", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: `nonexistent_${Date.now()}`, password: "Whatever1" }, }); expect(res.statusCode).toBe(401); }); it("wrong password returns 401", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "admin", password: "WrongPass1" }, }); expect(res.statusCode).toBe(401); }); it("unknown username and wrong password take comparable time (no enumeration timing oracle)", async () => { // Both cases must return the identical 401 body, but a naive implementation // short-circuits on "user not found" before ever running the password // hash (scrypt), while "wrong password for a real user" always pays the // scrypt cost. That gap lets an attacker enumerate valid usernames purely // from response timing even though the status code and body are identical. // See getDummyHash() in apps/api/src/plugins/auth.ts; it equalizes cost // by running verifyPassword against a dummy hash on the unknown-user path. const SAMPLES = 10; const median = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return sorted[Math.floor(sorted.length / 2)]; }; const unknownUserTimes: number[] = []; for (let i = 0; i < SAMPLES; i++) { const start = performance.now(); await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: `nonexistent_${uid()}_${i}`, password: "Whatever1" }, }); unknownUserTimes.push(performance.now() - start); } const wrongPasswordTimes: number[] = []; for (let i = 0; i < SAMPLES; i++) { const start = performance.now(); await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "admin", password: `WrongPass1_${i}` }, }); wrongPasswordTimes.push(performance.now() - start); } const unknownMedian = median(unknownUserTimes); const wrongPasswordMedian = median(wrongPasswordTimes); const ratio = Math.max(unknownMedian, wrongPasswordMedian) / Math.max(1, Math.min(unknownMedian, wrongPasswordMedian)); // A real (unfixed) timing oracle shows up as 5-10x+ here (unknown-user // returns near-instantly; wrong-password waits on scrypt). Bound at 3x to // absorb normal event-loop/GC jitter while still catching a regression. expect(ratio).toBeLessThan(3); }, 30_000); it("failed logins generate LOGIN_FAILED audit events", async () => { const marker = uid(); // Trigger a failed login with a unique username await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: marker, password: "Whatever1" }, }); const res = await testApp.app.inject({ method: "GET", url: "/api/v1/audit-log?action=LOGIN_FAILED&limit=50", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); const match = body.entries.find( (e: any) => e.action === "LOGIN_FAILED" && e.details?.username === marker, ); expect(match).toBeDefined(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // SESSION EDGE CASES // ═══════════════════════════════════════════════════════════════════════════ describe("Session edge cases", () => { it("no token on session endpoint returns 401", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", }); expect(res.statusCode).toBe(401); }); it("expired session token returns 401", async () => { // Login to get a valid session const token = await loginAs("admin", "Adminpass1"); // Manually expire the session in the DB await db .update(schema.sessions) .set({ expiresAt: new Date(Date.now() - 60_000) }) .where(eq(schema.sessions.id, token)); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(401); }); }); // ═══════════════════════════════════════════════════════════════════════════ // PASSWORD CHANGE SIDE EFFECTS // ═══════════════════════════════════════════════════════════════════════════ describe("Password change side effects", () => { it("changing password invalidates other sessions", async () => { const { username, password } = await createUser(); // Create two sessions const token1 = await loginAs(username, password); const token2 = await loginAs(username, password); // Verify both sessions work const check1 = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${token1}` }, }); expect(check1.statusCode).toBe(200); const check2 = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${token2}` }, }); expect(check2.statusCode).toBe(200); // Change password via session 1 const changeRes = await testApp.app.inject({ method: "POST", url: "/api/auth/change-password", headers: { authorization: `Bearer ${token1}` }, payload: { currentPassword: password, newPassword: "NewValid1" }, }); expect(changeRes.statusCode).toBe(200); // Session 1 should still work (it's the current session) const after1 = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${token1}` }, }); expect(after1.statusCode).toBe(200); // Session 2 should now be invalid const after2 = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${token2}` }, }); expect(after2.statusCode).toBe(401); }); it("changing password revokes API keys", async () => { const { username, password } = await createUser(); const token = await loginAs(username, password); // Create an API key const createKeyRes = await testApp.app.inject({ method: "POST", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${token}` }, payload: { name: "test-key" }, }); expect(createKeyRes.statusCode).toBe(201); const apiKey = JSON.parse(createKeyRes.body).key; // Verify the key works (hit a public-ish endpoint that still reads auth) const keyCheck = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${apiKey}` }, }); expect(keyCheck.statusCode).toBe(200); // Change password const changeRes = await testApp.app.inject({ method: "POST", url: "/api/auth/change-password", headers: { authorization: `Bearer ${token}` }, payload: { currentPassword: password, newPassword: "NewValid2" }, }); expect(changeRes.statusCode).toBe(200); // API key should now be revoked const keyAfter = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${apiKey}` }, }); expect(keyAfter.statusCode).toBe(401); }); }); // ═══════════════════════════════════════════════════════════════════════════ // PASSWORD RESET SIDE EFFECTS (admin resets another user) // ═══════════════════════════════════════════════════════════════════════════ describe("Password reset side effects", () => { it("admin reset invalidates target user sessions", async () => { const { username, password, id } = await createUser(); const userToken = await loginAs(username, password); // Verify user session works const before = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${userToken}` }, }); expect(before.statusCode).toBe(200); // Admin resets the user's password const resetRes = await testApp.app.inject({ method: "POST", url: `/api/auth/users/${id}/reset-password`, headers: { authorization: `Bearer ${adminToken}` }, payload: { newPassword: "ResetPass1" }, }); expect(resetRes.statusCode).toBe(200); // User session should now be invalid const after = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${userToken}` }, }); expect(after.statusCode).toBe(401); }); it("admin reset revokes target user API keys", async () => { const { username, password, id } = await createUser(); const userToken = await loginAs(username, password); // Create an API key for the target user const createKeyRes = await testApp.app.inject({ method: "POST", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${userToken}` }, payload: { name: "target-key" }, }); expect(createKeyRes.statusCode).toBe(201); const apiKey = JSON.parse(createKeyRes.body).key; // Verify the key works const keyBefore = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${apiKey}` }, }); expect(keyBefore.statusCode).toBe(200); // Admin resets the user's password const resetRes = await testApp.app.inject({ method: "POST", url: `/api/auth/users/${id}/reset-password`, headers: { authorization: `Bearer ${adminToken}` }, payload: { newPassword: "ResetPass2" }, }); expect(resetRes.statusCode).toBe(200); // API key should now be revoked const keyAfter = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${apiKey}` }, }); expect(keyAfter.statusCode).toBe(401); }); }); // ═══════════════════════════════════════════════════════════════════════════ // REGISTER VALIDATION // ═══════════════════════════════════════════════════════════════════════════ describe("Register validation", () => { it("invalid username chars returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "bad user!@#", password: "ValidPass1" }, }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); }); it("username too short (2 chars) returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: "ab", password: "ValidPass1" }, }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); }); it("weak password returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: uid(), password: "weak" }, }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); }); it("non-existent team name returns 400", async () => { const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: uid(), password: "ValidPass1", team: `ghost_team_${Date.now()}`, }, }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).code).toBe("VALIDATION_ERROR"); }); it("unknown role defaults to user", async () => { const username = uid(); const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username, password: "ValidPass1", role: "bogus" }, }); expect(res.statusCode).toBe(201); const body = JSON.parse(res.body); expect(body.role).toBe("user"); }); it("delete non-existent user returns 404", async () => { const res = await testApp.app.inject({ method: "DELETE", url: "/api/auth/users/00000000-0000-0000-0000-000000000000", headers: { authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(404); }); }); // ═══════════════════════════════════════════════════════════════════════════ // FORCED PASSWORD CHANGE GATE // ═══════════════════════════════════════════════════════════════════════════ describe("Forced password change gate", () => { // The register route leaves mustChangePassword=true; log straight in // without clearing it so the gate is active for the session. async function loginWithMustChange(): Promise<{ password: string; token: string }> { const username = uid(); const password = "ValidPass1"; const res = await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username, password, role: "admin" }, }); if (res.statusCode !== 201) { throw new Error(`register failed: ${res.statusCode} ${res.body}`); } return { password, token: await loginAs(username, password) }; } it("keeps public endpoints reachable while the flag is set", async () => { const { token } = await loginWithMustChange(); // Regression: /api/v1/health returned 403 here, tripping the SPA's // "Reconnecting to server" banner on the forced change-password screen. const health = await testApp.app.inject({ method: "GET", url: "/api/v1/health", headers: { authorization: `Bearer ${token}` }, }); expect(health.statusCode).toBe(200); }); it("blocks protected endpoints until the password is changed", async () => { const { password, token } = await loginWithMustChange(); const blocked = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${token}` }, }); expect(blocked.statusCode).toBe(403); expect(JSON.parse(blocked.body).code).toBe("MUST_CHANGE_PASSWORD"); const change = await testApp.app.inject({ method: "POST", url: "/api/auth/change-password", headers: { authorization: `Bearer ${token}` }, payload: { currentPassword: password, newPassword: "RotatedPass1" }, }); expect(change.statusCode).toBe(200); const after = await testApp.app.inject({ method: "GET", url: "/api/v1/api-keys", headers: { authorization: `Bearer ${token}` }, }); expect(after.statusCode).toBe(200); }); });