From 75770073a6a05c1d0ebbad983ff697608ec2e7c8 Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 19 Aug 2026 02:34:17 +0200 Subject: [PATCH] Tighten PO payload validator (disc/vat range, raw_ocr shape, PB filter escape) - Validate disc_percent and vat_percent are finite numbers in [0, 100] - Reject raw_ocr arrays (was passing typeof === 'object' check) - Escape backslashes before quotes in matchSite's PocketBase filter Co-Authored-By: Claude --- src/lib/purchase-order-schema.test.ts | 31 +++++++++++++++++++++++++++ src/lib/purchase-order-schema.ts | 22 +++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/lib/purchase-order-schema.test.ts b/src/lib/purchase-order-schema.test.ts index ea2627e..9647423 100644 --- a/src/lib/purchase-order-schema.test.ts +++ b/src/lib/purchase-order-schema.test.ts @@ -56,6 +56,37 @@ describe("validatePurchaseOrderPayload", () => { expect(result.ok).toBe(false); }); + it("rejects item with disc_percent > 100", () => { + const result = validatePurchaseOrderPayload({ + ...VALID_PAYLOAD, + items: [{ description: "x", qty: 1, rate: 10, disc_percent: 200 }], + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.details?.items).toMatch(/disc_percent/); + }); + + it("rejects item with disc_percent that is not a number", () => { + const result = validatePurchaseOrderPayload({ + ...VALID_PAYLOAD, + items: [{ description: "x", qty: 1, rate: 10, disc_percent: "foo" }], + }); + expect(result.ok).toBe(false); + }); + + it("rejects item with negative vat_percent", () => { + const result = validatePurchaseOrderPayload({ + ...VALID_PAYLOAD, + items: [{ description: "x", qty: 1, rate: 10, vat_percent: -5 }], + }); + expect(result.ok).toBe(false); + }); + + it("rejects raw_ocr that is an array", () => { + const result = validatePurchaseOrderPayload({ ...VALID_PAYLOAD, raw_ocr: [] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.details?.raw_ocr).toBeDefined(); + }); + it("rejects bad date format", () => { const result = validatePurchaseOrderPayload({ ...VALID_PAYLOAD, diff --git a/src/lib/purchase-order-schema.ts b/src/lib/purchase-order-schema.ts index ee9cb43..cfed077 100644 --- a/src/lib/purchase-order-schema.ts +++ b/src/lib/purchase-order-schema.ts @@ -82,6 +82,24 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult { if (typeof it.rate !== "number" || it.rate < 0) { itemErrors.push(`items[${i}].rate: must be a number >= 0`); } + if ( + it.disc_percent !== undefined && + (typeof it.disc_percent !== "number" || + !Number.isFinite(it.disc_percent) || + it.disc_percent < 0 || + it.disc_percent > 100) + ) { + itemErrors.push(`items[${i}].disc_percent: must be a number in [0, 100]`); + } + if ( + it.vat_percent !== undefined && + (typeof it.vat_percent !== "number" || + !Number.isFinite(it.vat_percent) || + it.vat_percent < 0 || + it.vat_percent > 100) + ) { + itemErrors.push(`items[${i}].vat_percent: must be a number in [0, 100]`); + } }); if (itemErrors.length > 0) { details.items = itemErrors.join("; "); @@ -113,7 +131,7 @@ export function validatePurchaseOrderPayload(input: unknown): ValidationResult { } } - if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object") { + if (p.raw_ocr === undefined || p.raw_ocr === null || typeof p.raw_ocr !== "object" || Array.isArray(p.raw_ocr)) { details.raw_ocr = "must be an object"; } @@ -150,7 +168,7 @@ type PbLike = { }; export async function matchSite(pb: PbLike, projectCode: string): Promise { - const safe = projectCode.replace(/"/g, '\\"'); + const safe = projectCode.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const exact = await pb.collection("sites").getFullList({ filter: `project_code = "${safe}"`,