fix: 2026-06-09 full-codebase audit hardening
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>
This commit is contained in:
@@ -1,12 +1,73 @@
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
|
||||
});
|
||||
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", () => {
|
||||
@@ -39,14 +133,88 @@ describe("POST /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 refresh token cookie", async () => {
|
||||
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).toBeLessThan(500);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user