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

@@ -1,7 +1,7 @@
import { ZodSchema, z } from "zod";
import { z } from "zod";
export function parseBody<T>(
schema: ZodSchema<T>,
schema: z.ZodType<T>,
body: unknown,
): { data: T } | { error: string } {
try {
@@ -15,3 +15,108 @@ export function parseBody<T>(
return { error: "Neplatný požadavek" };
}
}
// ── Shared form-coercion helpers ──────────────────────────────────────────
// HTTP bodies arrive as strings (multipart) or numbers (JSON). These helpers
// coerce number|string → number with a NaN/Infinity guard, replacing the
// `z.union([z.number(), z.string()]).transform(Number)` idiom that was
// duplicated ~150× across schemas and silently produced NaN on bad input
// (which then flowed into Prisma / business math). User-facing messages Czech.
const coerceNum = (v: unknown): number =>
typeof v === "number"
? v
: typeof v === "string" && v.trim() !== ""
? Number(v)
: NaN;
/** number|string → number; rejects NaN/Infinity/empty. */
export const numberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n), { message: "Neplatné číslo" });
/** number|string → number within [min,max]; rejects NaN/out-of-range. */
export const numberInRange = (min: number, max: number, message?: string) =>
z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n >= min && n <= max, {
message: message ?? `Číslo musí být mezi ${min} a ${max}`,
});
/** number|string → non-negative number (e.g. quantities, prices, odometers). */
export const nonNegativeNumberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n >= 0, {
message: "Číslo nesmí být záporné",
});
/** number|string → positive number ( > 0 ). */
export const positiveNumberFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isFinite(n) && n > 0, {
message: "Číslo musí být kladné",
});
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
export const intIdFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isInteger(n) && n > 0, { message: "Neplatné ID" });
/** number|string|null → number|null; rejects NaN when a value is present. */
export const nullableNumberFromForm = z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
.refine((n) => n === null || Number.isFinite(n), {
message: "Neplatné číslo",
});
/** number|string|null → positive-int id | null; rejects NaN/≤0 when present. */
export const nullableIntIdFromForm = z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null || v === "" ? null : coerceNum(v)))
.refine((n) => n === null || (Number.isInteger(n) && n > 0), {
message: "Neplatné ID",
});
/** Truthy form flag → boolean: true | 1 | "1" | "true" → true. */
export const booleanFromForm = z.preprocess(
(v) => v === true || v === 1 || v === "1" || v === "true",
z.boolean(),
);
/**
* YYYY-MM-DD date string. Tolerant of a trailing time component: an edit form
* round-trips a `@db.Date` that the `Date.toJSON` override serialises as
* "YYYY-MM-DDT00:00:00", so we strip anything after the date before validating
* (and the validated output is always the date-only `YYYY-MM-DD`).
*/
export const isoDateString = z.preprocess(
(v) => (typeof v === "string" ? v.slice(0, 10) : v),
z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD"),
);
/** HH:MM (24h) time string. Tolerant of a trailing ":ss" seconds component. */
export const timeString = z.preprocess(
(v) =>
typeof v === "string" && /^\d{2}:\d{2}:\d{2}/.test(v) ? v.slice(0, 5) : v,
z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
);
/**
* An email field that also accepts an empty string (meaning "not set"). Many
* settings/supplier forms submit "" for un-filled optional emails; a bare
* `.email()` would 400 the whole form. Pair with `.nullish()` at the call site.
*/
export const emailOrEmpty = z.union([
z.literal(""),
z.string().max(255).email("Neplatný email"),
]);