import { describe, it, expect, beforeAll, afterAll } from "vitest"; import bcrypt from "bcryptjs"; import { buildApp, extractCookie } from "./helpers"; import prisma from "../config/database"; import { config } from "../config/env"; let app: Awaited>; // Fixture prefix so cleanup never touches real data. const N = "auth_test_"; const TEST_USERNAME = `${N}user`; const TEST_PASSWORD = "Sup3r-Secret-Pw!"; let testUserId: number; /** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */ function rawSetCookie(res: { headers: Record }, name: string) { const cookies = res.headers["set-cookie"]; if (!cookies) return undefined; const arr = Array.isArray(cookies) ? cookies : [cookies]; return arr.find((c: string) => c.startsWith(`${name}=`)); } beforeAll(async () => { app = await buildApp(); // Clean up any leftover fixture from a previous failed run, plus its tokens. const leftovers = await prisma.users.findMany({ where: { username: { startsWith: N } }, select: { id: true }, }); if (leftovers.length) { await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: leftovers.map((u) => u.id) } }, }); await prisma.users.deleteMany({ where: { username: { startsWith: N } } }); } const role = await prisma.roles.findFirst(); if (!role) throw new Error("Test setup: no roles found — seed the database"); // Hash the known password with the SAME bcrypt cost the app uses so login's // bcrypt.compare succeeds. const passwordHash = await bcrypt.hash( TEST_PASSWORD, config.security.bcryptCost, ); const user = await prisma.users.create({ data: { username: TEST_USERNAME, email: `${N}user@test.local`, password_hash: passwordHash, first_name: "Auth", last_name: "Test", is_active: true, totp_enabled: false, role_id: role.id, }, }); testUserId = user.id; }); afterAll(async () => { await prisma.refresh_tokens .deleteMany({ where: { user_id: testUserId } }) .catch(() => {}); await prisma.users .deleteMany({ where: { username: { startsWith: N } } }) .catch(() => {}); await app.close(); }); describe("POST /api/admin/login", () => { it("returns 401 for invalid credentials", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/login", payload: { username: "nonexistent", password: "wrong" }, }); expect(res.statusCode).toBe(401); expect(res.json().success).toBe(false); }); it("returns 400 for missing fields", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/login", payload: {}, }); expect(res.statusCode).toBe(400); }); it("returns 401 for a valid user with the wrong password", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/login", payload: { username: TEST_USERNAME, password: "definitely-wrong" }, }); expect(res.statusCode).toBe(401); expect(res.json().success).toBe(false); }); it("returns 200 with an access token and a refresh cookie on valid credentials", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/login", payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); expect(typeof body.data.access_token).toBe("string"); expect(body.data.access_token.length).toBeGreaterThan(0); expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry); expect(body.data.user.userId).toBe(testUserId); expect(body.data.user.username).toBe(TEST_USERNAME); // The refresh token must be set as an httpOnly, SameSite=Strict cookie. const cookie = extractCookie(res, "refresh_token"); expect(cookie).toBeTruthy(); const rawCookie = rawSetCookie(res, "refresh_token")!; expect(rawCookie).toMatch(/HttpOnly/i); expect(rawCookie).toMatch(/SameSite=Strict/i); }); }); describe("POST /api/admin/refresh", () => { it("returns 401 without refresh token", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/refresh", }); expect(res.statusCode).toBe(401); }); it("returns 401 for an invalid/unknown refresh token", async () => { const res = await app.inject({ method: "POST", url: "/api/admin/refresh", cookies: { refresh_token: "not-a-real-token" }, }); expect(res.statusCode).toBe(401); expect(res.json().success).toBe(false); }); it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => { // First log in to obtain a valid refresh cookie. const loginRes = await app.inject({ method: "POST", url: "/api/admin/login", payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, }); expect(loginRes.statusCode).toBe(200); const originalRefresh = extractCookie(loginRes, "refresh_token")!; expect(originalRefresh).toBeTruthy(); const refreshRes = await app.inject({ method: "POST", url: "/api/admin/refresh", cookies: { refresh_token: originalRefresh }, }); expect(refreshRes.statusCode).toBe(200); const body = refreshRes.json(); expect(body.success).toBe(true); expect(typeof body.data.access_token).toBe("string"); expect(body.data.user.userId).toBe(testUserId); // The refresh token must be ROTATED: a new cookie value, different from // the one we presented. const rotated = extractCookie(refreshRes, "refresh_token"); expect(rotated).toBeTruthy(); expect(rotated).not.toBe(originalRefresh); // Reuse-detection: presenting the now-consumed original token must fail. const reuseRes = await app.inject({ method: "POST", url: "/api/admin/refresh", cookies: { refresh_token: originalRefresh }, }); expect(reuseRes.statusCode).toBe(401); }); }); describe("POST /api/admin/logout", () => { it("clears the refresh token cookie", async () => { // Log in so logout has a real token to delete. const loginRes = await app.inject({ method: "POST", url: "/api/admin/login", payload: { username: TEST_USERNAME, password: TEST_PASSWORD }, }); const refresh = extractCookie(loginRes, "refresh_token")!; const res = await app.inject({ method: "POST", url: "/api/admin/logout", cookies: { refresh_token: refresh }, }); expect(res.statusCode).toBe(200); // The response must actively clear the cookie — an expiry in the past // and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past). const raw = rawSetCookie(res, "refresh_token"); expect(raw).toBeTruthy(); const cleared = /Max-Age=0/i.test(raw!) || /Expires=Thu, 01 Jan 1970/i.test(raw!) || /^refresh_token=;/.test(raw!); expect(cleared).toBe(true); // The deleted token must no longer be usable for refresh. const reuseRes = await app.inject({ method: "POST", url: "/api/admin/refresh", cookies: { refresh_token: refresh }, }); expect(reuseRes.statusCode).toBe(401); }); });