Files
app/src/__tests__/auth.test.ts
BOHA 519edce373 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>
2026-06-09 06:45:26 +02:00

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);
});
});