Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
7.1 KiB
TypeScript
221 lines
7.1 KiB
TypeScript
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<ReturnType<typeof buildApp>>;
|
|
|
|
// 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<string, any> }, 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);
|
|
});
|
|
});
|