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:
@@ -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"),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user