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

@@ -627,19 +627,22 @@ describe("Item CRUD", () => {
expect(body.data.name).toBe(`${N}item1_updated`);
});
it("deactivates an item (soft delete)", async () => {
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
// The current API HARD-deletes the item — the route does
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
// test name reflects that real behavior so a future intended switch to
// soft-delete would (correctly) make this test fail and prompt a review,
// rather than the old "soft delete" name masking the mismatch.
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
// The current API hard-deletes the item (the route does
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
// Verify the row is gone rather than checking is_active.
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// The row is actually gone — a subsequent GET 404s.
const checkRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${createdItemId}`,
@@ -1030,6 +1033,124 @@ describe("Issue flow", () => {
});
});
// ---------------------------------------------------------------------------
// 6b. FIFO ordering — the OLDEST batch is consumed first
// ---------------------------------------------------------------------------
describe("Issue FIFO ordering (oldest batch first)", () => {
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
// Build two batches for one item via two confirmed receipts.
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_fifo`, unit: "ks" },
});
const fifoItemId = itemRes.json().data.id as number;
const locA = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc A" },
})
).json().data.id as number;
const locB = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc B" },
})
).json().data.id as number;
// Helper: create + confirm a receipt of `qty` at `locId`.
const confirmReceipt = async (qty: number, locId: number) => {
const r = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}fifo_receipt`,
items: [
{
item_id: fifoItemId,
quantity: qty,
unit_price: 10,
location_id: locId,
},
],
},
});
const id = r.json().data.id as number;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${id}/confirm`,
headers: authHeader(adminToken),
});
};
// batchEarly is created FIRST (lower id); batchLate is created SECOND
// (higher id). We then flip their received_at so the HIGHER-id batch is the
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
// by insertion id.
await confirmReceipt(10, locA); // → batch with the lower id
await confirmReceipt(10, locB); // → batch with the higher id
const batchesRaw = await prisma.sklad_batches.findMany({
where: { item_id: fifoItemId },
orderBy: { id: "asc" },
});
expect(batchesRaw.length).toBe(2);
const lowerId = batchesRaw[0]; // created first
const higherId = batchesRaw[1]; // created second
// Make the HIGHER-id batch the OLDEST by received_at.
await prisma.sklad_batches.update({
where: { id: lowerId.id },
data: { received_at: new Date("2021-01-01T00:00:00Z") },
});
await prisma.sklad_batches.update({
where: { id: higherId.id },
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
});
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
// (the higher-id one we back-dated), partially depleting it.
const issueRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}fifo_issue`,
items: [{ item_id: fifoItemId, quantity: 5 }],
},
});
expect(issueRes.statusCode).toBe(201);
const issueId = issueRes.json().data.id as number;
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
// wrongly sorted by id it would have consumed the lower-id batch instead.
const oldest = await prisma.sklad_batches.findUnique({
where: { id: higherId.id },
});
const newest = await prisma.sklad_batches.findUnique({
where: { id: lowerId.id },
});
expect(Number(oldest!.quantity)).toBe(5);
expect(Number(newest!.quantity)).toBe(10);
});
});
// ---------------------------------------------------------------------------
// 7. Reservation flow
// ---------------------------------------------------------------------------