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:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -12,22 +12,25 @@ import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const OrderPdfBodySchema = z
.object({
items: z
.array(
z.object({
description: z.string(),
quantity: z.number().min(0).finite(),
unit: z.string(),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().finite(),
}),
)
.optional(),
})
.passthrough();
// Plain object (Zod strips unknown keys rather than rejecting) — the frontend
// may send extra item fields (id, position, item_description) we don't use.
const OrderPdfItemSchema = z.object({
description: z.string().max(8000),
quantity: z.number().min(0).finite(),
unit: z.string().max(255),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().min(0).max(100).finite(),
});
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
// handler no longer reads them as untyped passthrough keys.
const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
});
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -290,8 +293,20 @@ export default async function ordersPdfRoutes(
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
// Use custom items from body if provided, otherwise order items
const customItemsRaw = body.items;
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
let items: Array<{
description: string;
quantity: number;
@@ -301,7 +316,7 @@ export default async function ordersPdfRoutes(
vat_rate: number;
}> = [];
if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) {
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,