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:
@@ -9,6 +9,49 @@ import {
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
|
||||
/**
|
||||
* Assert two consecutive document numbers (a) keep an identical format / year
|
||||
* prefix and (b) increment by exactly one — without hard-coding which
|
||||
* `company_settings` pattern is configured.
|
||||
*
|
||||
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
|
||||
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
|
||||
* of the two strings (the unchanged format/year portion) and parse the
|
||||
* remaining differing suffixes as integers — these are the rendered sequences.
|
||||
*/
|
||||
function assertStrictIncrement(num1: string, num2: string) {
|
||||
expect(num1).not.toBe(num2);
|
||||
expect(num2.length).toBe(num1.length); // same width ⇒ same format
|
||||
|
||||
let i = 0;
|
||||
while (i < num1.length && num1[i] === num2[i]) i++;
|
||||
const commonPrefix = num1.slice(0, i);
|
||||
// The format/year portion is shared and non-empty (the numbers don't differ
|
||||
// from the very first character).
|
||||
expect(commonPrefix.length).toBeGreaterThan(0);
|
||||
|
||||
const seq1 = Number(num1.slice(i));
|
||||
const seq2 = Number(num2.slice(i));
|
||||
expect(Number.isNaN(seq1)).toBe(false);
|
||||
expect(Number.isNaN(seq2)).toBe(false);
|
||||
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the (type, year) sequence row to a high `last_number` so the next
|
||||
* generated numbers land far above any real order/quotation in the test DB —
|
||||
* the generator's skip-taken retry then never fires and the strict +1
|
||||
* assertion is deterministic.
|
||||
*/
|
||||
async function seedSequence(type: string, lastNumber: number) {
|
||||
await prisma.number_sequences.deleteMany({ where: { type } });
|
||||
await prisma.number_sequences.create({
|
||||
data: { type, year: YEAR, last_number: lastNumber },
|
||||
});
|
||||
}
|
||||
|
||||
describe("generateSharedNumber", () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
@@ -24,10 +67,13 @@ describe("generateSharedNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
// Seed high so neither generated number collides with a real order/project
|
||||
// → the skip-taken retry never fires and the increment is strictly +1.
|
||||
await seedSequence("shared", 900000);
|
||||
const num1 = await generateSharedNumber();
|
||||
const num2 = await generateSharedNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +92,11 @@ describe("generateOfferNumber", () => {
|
||||
expect(num.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("increments on consecutive calls", async () => {
|
||||
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
|
||||
await seedSequence("offer", 900000);
|
||||
const num1 = await generateOfferNumber();
|
||||
const num2 = await generateOfferNumber();
|
||||
expect(num1).not.toBe(num2);
|
||||
assertStrictIncrement(num1, num2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,9 +124,17 @@ describe("previewSharedNumber", () => {
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number. With the
|
||||
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
||||
// Seed the counter HIGH so the preview lands far above any real
|
||||
// order/project in the test DB. Without this, a real row could already
|
||||
// hold the first sequence number — making the create collide on the unique
|
||||
// column, or making `second !== first` pass for the wrong reason (because
|
||||
// the counter happened to already point past `first`).
|
||||
await seedSequence("shared", 900000);
|
||||
const first = await previewSharedNumber();
|
||||
// Preconditions: the seeded preview really is free (so the create below is
|
||||
// safe) and is exactly what the generator would assign next.
|
||||
expect(await isSharedNumberTaken(first)).toBe(false);
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||
});
|
||||
@@ -117,9 +172,14 @@ describe("previewOfferNumber", () => {
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number; with the
|
||||
// skip-taken logic it's guaranteed free, so we can claim it.
|
||||
// Seed HIGH so the preview lands above any real quotation in the test DB
|
||||
// (see the shared-number test for the rationale): the create can't collide
|
||||
// and the skip-advance is genuinely exercised rather than passing because a
|
||||
// real row already advanced the preview.
|
||||
await seedSequence("offer", 900000);
|
||||
const first = await previewOfferNumber();
|
||||
expect(await isOfferNumberTaken(first)).toBe(false);
|
||||
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: { quotation_number: first },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user