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:
@@ -7,6 +7,7 @@ import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import planRoutes from "../routes/admin/plan";
|
||||
import { localDateStr } from "../utils/date";
|
||||
import {
|
||||
resolveCell,
|
||||
resolveGrid,
|
||||
@@ -31,6 +32,14 @@ import {
|
||||
const N = "wh_plan_";
|
||||
|
||||
let adminUserId: number;
|
||||
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
|
||||
// tests (and as the second employee in the multi-user bulk test). Creating our
|
||||
// own user — instead of grabbing "the second user in the DB" — guarantees it is
|
||||
// never accidentally the admin (which would make `toEqual([])` pass for the
|
||||
// wrong reason on a single-user DB), and that it is genuinely distinct so the
|
||||
// 2-employee aggregate assertion can't collapse to 1.
|
||||
let scopeUserId: number;
|
||||
let scopeRoleId: number;
|
||||
let noPermUserId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -43,11 +52,44 @@ beforeAll(async () => {
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminUserId = admin.id;
|
||||
|
||||
// For list-scoping tests: any non-admin user that is NOT the admin we use
|
||||
// for create tests. Just pick the second user.
|
||||
const allUsers = await prisma.users.findMany({ take: 2 });
|
||||
noPermUserId =
|
||||
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
|
||||
// Dedicated non-admin user + role for scoping/multi-user tests.
|
||||
const stamp = Date.now();
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: `scope_plan_${stamp}`,
|
||||
display_name: "Plan Scope Test",
|
||||
},
|
||||
});
|
||||
scopeRoleId = role.id;
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
username: `scope_plan_${stamp}`,
|
||||
first_name: "Scope",
|
||||
last_name: "User",
|
||||
email: `scope_plan_${stamp}@test.local`,
|
||||
password_hash:
|
||||
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||
role_id: role.id,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
scopeUserId = user.id;
|
||||
if (scopeUserId === adminUserId)
|
||||
throw new Error("scope user must be distinct from admin");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
|
||||
// leftover FK (none expected — its rows are note-prefixed and removed) can't
|
||||
// mask real failures.
|
||||
if (scopeUserId)
|
||||
await prisma.users
|
||||
.deleteMany({ where: { id: scopeUserId } })
|
||||
.catch(() => {});
|
||||
if (scopeRoleId)
|
||||
await prisma.roles
|
||||
.deleteMany({ where: { id: scopeRoleId } })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
|
||||
const result = assertNotPastDate("2000-01-01", true);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
|
||||
// Deterministic without mocking the clock: compute "today" exactly the way
|
||||
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
|
||||
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
|
||||
// would wrongly reject editing today's plan.
|
||||
const today = localDateStr(new Date());
|
||||
expect(assertNotPastDate(today, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects YESTERDAY (local Prague date) as a past date", () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1); // local-time yesterday
|
||||
const yesterday = localDateStr(d);
|
||||
const result = assertNotPastDate(yesterday, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => {
|
||||
);
|
||||
expect("data" in updated).toBe(true);
|
||||
if ("data" in updated) {
|
||||
expect(updated.data.note).toBe(`${N}updated`);
|
||||
expect((updated.data as any).note).toBe(`${N}updated`);
|
||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||
}
|
||||
});
|
||||
@@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listEntries(
|
||||
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false, // not admin
|
||||
);
|
||||
// A non-admin actor querying ANOTHER user's data is scoped to its own
|
||||
// user_id, so it sees none of the admin's entries. This can only be
|
||||
// [] for the right reason because scopeUserId !== adminUserId (asserted
|
||||
// in beforeAll).
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
);
|
||||
const rows = await listOverrides(
|
||||
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
|
||||
noPermUserId,
|
||||
scopeUserId, // dedicated distinct non-admin actor (never the admin)
|
||||
false,
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
@@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => {
|
||||
});
|
||||
|
||||
it("aggregates across multiple employees", async () => {
|
||||
// Two genuinely distinct employees (admin + the dedicated scope user) so
|
||||
// `users` can't collapse to 1 if the DB happened to have a single user.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId, noPermUserId],
|
||||
user_ids: [adminUserId, scopeUserId],
|
||||
date_from: "2096-08-03", // Friday
|
||||
date_to: "2096-08-03",
|
||||
include_weekends: true,
|
||||
|
||||
Reference in New Issue
Block a user