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,20 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { invoiceTotalWithVat } from "../services/invoices.service";
|
||||
|
||||
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
|
||||
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
|
||||
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
|
||||
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
|
||||
// for `Number(decimal)`) is actually caught.
|
||||
|
||||
const dec = (n: number | string) => new Prisma.Decimal(n);
|
||||
|
||||
// Helper so each test reads as a small declarative table of lines.
|
||||
function buildInvoice(opts: {
|
||||
applyVat: boolean;
|
||||
vatRate: number | null;
|
||||
currency?: string;
|
||||
items: Array<{
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
vat_rate: number | null;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
apply_vat: opts.applyVat,
|
||||
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
|
||||
currency: opts.currency ?? "CZK",
|
||||
invoice_items: opts.items.map((i) => ({
|
||||
quantity: i.quantity === null ? null : dec(i.quantity),
|
||||
unit_price: i.unit_price === null ? null : dec(i.unit_price),
|
||||
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("invoiceTotalWithVat", () => {
|
||||
it("calculates subtotal without VAT when apply_vat is false", () => {
|
||||
const inv = {
|
||||
apply_vat: false,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 2 },
|
||||
unit_price: { toNumber: () => 100 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
],
|
||||
};
|
||||
const inv = buildInvoice({
|
||||
applyVat: false,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(200);
|
||||
});
|
||||
|
||||
@@ -24,32 +49,72 @@ describe("invoiceTotalWithVat", () => {
|
||||
// Total VAT = 7.00 * 3 = 21.00
|
||||
// Subtotal = 33.33 * 3 = 99.99
|
||||
// Total = 99.99 + 21.00 = 120.99
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: Array.from({ length: 3 }, () => ({
|
||||
quantity: { toNumber: () => 1 },
|
||||
unit_price: { toNumber: () => 33.33 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: Array.from({ length: 3 }, () => ({
|
||||
quantity: 1,
|
||||
unit_price: 33.33,
|
||||
vat_rate: 21,
|
||||
})),
|
||||
};
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(120.99);
|
||||
});
|
||||
|
||||
it("handles null quantity and unit_price gracefully", () => {
|
||||
const inv = {
|
||||
apply_vat: true,
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
currency: "CZK",
|
||||
invoice_items: [
|
||||
{
|
||||
quantity: { toNumber: () => 0 },
|
||||
unit_price: { toNumber: () => 0 },
|
||||
vat_rate: { toNumber: () => 21 },
|
||||
},
|
||||
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
|
||||
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
|
||||
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
|
||||
// line proves the null line contributes nothing while the rest still totals.
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: null, unit_price: null, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
|
||||
],
|
||||
};
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(121);
|
||||
});
|
||||
|
||||
it("returns 0 when every line is null", () => {
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [{ quantity: null, unit_price: null, vat_rate: null }],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(0);
|
||||
});
|
||||
|
||||
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
|
||||
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
|
||||
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
|
||||
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
|
||||
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
|
||||
// Subtotal = 350, VAT = 63, Total = 413
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 15,
|
||||
items: [
|
||||
{ quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: 50, vat_rate: 12 },
|
||||
{ quantity: 1, unit_price: 100, vat_rate: 0 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(413);
|
||||
});
|
||||
|
||||
it("handles a negative (discount) line correctly", () => {
|
||||
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
|
||||
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
|
||||
// Subtotal = 800, VAT = 168, Total = 968
|
||||
const inv = buildInvoice({
|
||||
applyVat: true,
|
||||
vatRate: 21,
|
||||
items: [
|
||||
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||
{ quantity: 1, unit_price: -200, vat_rate: 21 },
|
||||
],
|
||||
});
|
||||
expect(invoiceTotalWithVat(inv)).toBe(968);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user