From b9bf17d3a5046b1effa9a766dd051f8bc51ee6d2 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 04:37:53 -0500 Subject: [PATCH] feat(purchaseorderline): reject non-finite polQty + polPrice at the schema layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `polQty` and `polPrice` were typed as bare `z.coerce.number()`, which accepts `Infinity` and `-Infinity` (zod's `.number()` rejects NaN by default but not the infinities). The coerce path also turns the string `"Infinity"` into the float, so even via JSON — which has no literal for Infinity — a client can land non-finite values in either column. Both columns are Sequelize `DOUBLE` and will happily store `inf`. An `inf` on a PO line silently corrupts every downstream calculation: PO totals, inventory valuation, GL reconciliation. Fix: extract `polQtyField` + `polPriceField` chained through `.finite()`, share between create and update bodies. Same pattern as the `cpayAmountField` (#172) and `injbAmountField` (#180) validators. Zero and negative values still pass — a $0 line is a real "freebie included with order" case, and a negative line is a valid inline credit/discount entry. Pinned in `tests/api/purchaseorderline.test.js` with 5 new tests covering: non-finite polQty rejection, non-finite polPrice rejection on POST and PATCH, and that zero + negative values still pass through (preserving the freebie / inline-discount use cases). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/schemas/purchaseorderline.schema.js | 27 +++++++++++++++--- tests/api/purchaseorderline.test.js | 37 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/app/schemas/purchaseorderline.schema.js b/app/schemas/purchaseorderline.schema.js index 8e424fa..397275c 100644 --- a/app/schemas/purchaseorderline.schema.js +++ b/app/schemas/purchaseorderline.schema.js @@ -8,11 +8,30 @@ const intIdParam = z.object({ id: z.coerce.number().int().positive(), }); +// PO-line quantity + per-unit price. Both are stored as Sequelize +// DOUBLE columns. zod's `.number()` rejects NaN by default but lets +// `Infinity` / `-Infinity` slip through — and the coerce path turns +// the string `"Infinity"` into the float. An `inf` in either column +// would silently corrupt every downstream calculation (PO totals, +// inventory valuation, reconciliation reports). Pin both to finite +// real numbers at the boundary. +// +// Zero and negative values still pass — a $0 line is a real "freebie +// included with order" case; a negative line can be used for an +// inline credit/discount entry on the PO. Mirrors the cpayAmount / +// injbAmount validators (#172 / #180). +const polQtyField = z.coerce.number().finite({ + message: 'polQty must be a finite number.', +}); +const polPriceField = z.coerce.number().finite({ + message: 'polPrice must be a finite number.', +}); + const createBody = z.object({ polpoh: z.coerce.number().int().positive(), polItemDesc: z.string().min(1).max(1000), - polQty: z.coerce.number(), - polPrice: z.coerce.number(), + polQty: polQtyField, + polPrice: polPriceField, polInvtId: z.coerce.number().int().positive(), }).strict({ message: 'Unexpected field in body. Whitelist: polpoh, polItemDesc, polQty, polPrice, polInvtId.', @@ -20,8 +39,8 @@ const createBody = z.object({ const updateBody = z.object({ polItemDesc: z.string().min(1).max(1000).optional(), - polQty: z.coerce.number().optional(), - polPrice: z.coerce.number().optional(), + polQty: polQtyField.optional(), + polPrice: polPriceField.optional(), polInvtId: z.coerce.number().int().positive().optional(), }).strict({ message: 'Unexpected field in body. Whitelist: polItemDesc, polQty, polPrice, polInvtId.', diff --git a/tests/api/purchaseorderline.test.js b/tests/api/purchaseorderline.test.js index 6d56f60..bbd86f8 100644 --- a/tests/api/purchaseorderline.test.js +++ b/tests/api/purchaseorderline.test.js @@ -64,4 +64,41 @@ describe('PurchaseOrderLine body validation', () => { }); expect(res.status).toBe(400); }); + + test('POST rejects non-finite polQty (string "Infinity" coerces to the float)', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 'Infinity', polPrice: 5, polInvtId: 1, + }); + expect(res.status).toBe(400); + }); + + test('POST rejects non-finite polPrice', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Widget', polQty: 10, polPrice: '-Infinity', polInvtId: 1, + }); + expect(res.status).toBe(400); + }); + + test('POST accepts zero polQty / polPrice (freebie line)', async () => { + // A $0 PO line is a real "free sample included" case; pin + // that the .finite() refinement doesn't accidentally block 0. + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Freebie', polQty: 0, polPrice: 0, polInvtId: 1, + }); + expect(res.status).not.toBe(400); + }); + + test('POST accepts negative polQty / polPrice (inline credit / discount)', async () => { + const res = await request(app).post('/v1/purchaseorderline').set('authKey', 'any').send({ + polpoh: 1, polItemDesc: 'Volume discount', polQty: -1, polPrice: -10, polInvtId: 1, + }); + expect(res.status).not.toBe(400); + }); + + test('PATCH rejects non-finite polPrice', async () => { + const res = await request(app).patch('/v1/purchaseorderline/1').set('authKey', 'any').send({ + polPrice: 'Infinity', + }); + expect(res.status).toBe(400); + }); });