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>
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
|
import { verifyAccessToken, hashToken } from "../services/auth";
|
|
import jwt from "jsonwebtoken";
|
|
import { config } from "../config/env";
|
|
import prisma from "../config/database";
|
|
|
|
// Prefix for fixtures so cleanup never touches real data.
|
|
const N = "auth_svc_test_";
|
|
|
|
let testUserId: number;
|
|
let testUsername: string;
|
|
let testRoleName: string | null;
|
|
|
|
beforeAll(async () => {
|
|
// Clean up any leftover fixtures from a previous failed run.
|
|
await prisma.users
|
|
.deleteMany({ where: { username: { startsWith: N } } })
|
|
.catch(() => {});
|
|
|
|
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
|
|
// returns a full AuthData. We don't need a specific role — any active user
|
|
// works — but we record its role so we can assert roleName precisely.
|
|
const role = await prisma.roles.findFirst();
|
|
if (!role) throw new Error("Test setup: no roles found — seed the database");
|
|
|
|
const user = await prisma.users.create({
|
|
data: {
|
|
username: `${N}user`,
|
|
email: `${N}user@test.local`,
|
|
// DUMMY_HASH used elsewhere in the suite; we never verify a password
|
|
// here, only that the token's `sub` resolves to this user.
|
|
password_hash:
|
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
|
first_name: "Auth",
|
|
last_name: "Service",
|
|
is_active: true,
|
|
role_id: role.id,
|
|
},
|
|
include: { roles: true },
|
|
});
|
|
testUserId = user.id;
|
|
testUsername = user.username;
|
|
testRoleName = user.roles?.name ?? null;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.users
|
|
.deleteMany({ where: { username: { startsWith: N } } })
|
|
.catch(() => {});
|
|
});
|
|
|
|
describe("auth service", () => {
|
|
describe("verifyAccessToken", () => {
|
|
it("returns AuthData for a valid token signed for a real user", async () => {
|
|
const token = jwt.sign(
|
|
{ sub: testUserId, username: testUsername, role: testRoleName },
|
|
config.jwt.secret,
|
|
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
|
|
);
|
|
|
|
const result = await verifyAccessToken(token);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.userId).toBe(testUserId);
|
|
expect(result!.username).toBe(testUsername);
|
|
expect(result!.roleName).toBe(testRoleName);
|
|
// Permissions resolve to an array (admins get all, others get role perms).
|
|
expect(Array.isArray(result!.permissions)).toBe(true);
|
|
});
|
|
|
|
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
|
|
const consoleSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const result = await verifyAccessToken("invalid-token");
|
|
expect(result).toBeNull();
|
|
// Invalid/expired access tokens are an expected condition (the client
|
|
// refreshes on the resulting 401), so verifyAccessToken deliberately
|
|
// does NOT log them as errors — only genuinely unexpected failures.
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("returns null WITHOUT logging for an expired JWT", async () => {
|
|
const consoleSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
const expiredToken = jwt.sign(
|
|
{ sub: 1, username: "test", role: "user" },
|
|
config.jwt.secret,
|
|
{ expiresIn: -1 },
|
|
);
|
|
const result = await verifyAccessToken(expiredToken);
|
|
expect(result).toBeNull();
|
|
expect(consoleSpy).not.toHaveBeenCalled();
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("hashToken", () => {
|
|
it("matches the known SHA-256 vector for a fixed input", () => {
|
|
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
|
|
// different but still 64-hex-deterministic hash) is caught.
|
|
expect(hashToken("hello")).toBe(
|
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
|
);
|
|
});
|
|
|
|
it("produces deterministic SHA-256 hex output", () => {
|
|
const t1 = hashToken("hello");
|
|
const t2 = hashToken("hello");
|
|
expect(t1).toBe(t2);
|
|
expect(t1).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it("produces different hashes for different inputs", () => {
|
|
const t1 = hashToken("a");
|
|
const t2 = hashToken("b");
|
|
expect(t1).not.toBe(t2);
|
|
});
|
|
});
|
|
});
|