Files
app/src/__tests__/auth.service.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

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