Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions app/schemas/inventoryitem.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,33 @@ const intIdParam = z.object({
id: z.coerce.number().int().positive(),
});

// `invitQty` is the on-hand inventory quantity stored as a Sequelize
// DOUBLE column. zod's `.number()` rejects NaN by default but allows
// `Infinity` / `-Infinity` — and the coerce path turns the string
// `"Infinity"` into the float. An `inf` qty silently corrupts every
// downstream consumer that does arithmetic against it (PO line
// receiving, inventory-transaction net-position, valuation reports).
//
// Add `.finite()` at the boundary. Zero stays valid (a stocked item
// momentarily at 0 on-hand) and negatives stay valid (backorders, or
// historical fractional reconciliation entries that some accounting
// flows allow). Mirrors the polQtyField / polPriceField validators
// from #194 and btHourlyRateField from #206.
const invitQtyField = z.coerce.number().finite({
message: 'invitQty must be a finite number.',
});

const createInventoryItemBody = z.object({
invitDescription: z.string().min(1).max(1000),
invitQty: z.coerce.number(),
invitQty: invitQtyField,
invitCompId: z.coerce.number().int().positive().optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: invitDescription, invitQty, invitCompId.',
});

const updateInventoryItemBody = z.object({
invitDescription: z.string().min(1).max(1000).optional(),
invitQty: z.coerce.number().optional(),
invitQty: invitQtyField.optional(),
}).strict({
message: 'Unexpected field in body. Whitelist: invitDescription, invitQty.',
});
Expand Down
34 changes: 34 additions & 0 deletions tests/api/inventoryitem.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ describe('InventoryItem body validation', () => {
.send({ invitDescription: 'X' });
expect(res.status).toBe(400);
});

test('POST rejects non-finite invitQty (string "Infinity" coerces to the float)', async () => {
const res = await request(app)
.post('/v1/inventoryitem')
.set('authKey', 'any')
.send({ invitDescription: 'Widget', invitQty: 'Infinity' });
expect(res.status).toBe(400);
});

test('POST accepts zero invitQty (item at 0 on-hand)', async () => {
// Pin that the .finite() guard doesn't accidentally block 0 —
// an item momentarily out of stock should still be modelable.
const res = await request(app)
.post('/v1/inventoryitem')
.set('authKey', 'any')
.send({ invitDescription: 'Widget', invitQty: 0 });
expect(res.status).not.toBe(400);
});

test('POST accepts negative invitQty (backorder / reconciliation entries)', async () => {
const res = await request(app)
.post('/v1/inventoryitem')
.set('authKey', 'any')
.send({ invitDescription: 'Backorder', invitQty: -3 });
expect(res.status).not.toBe(400);
});

test('PATCH rejects -Infinity invitQty', async () => {
const res = await request(app)
.patch('/v1/inventoryitem/1')
.set('authKey', 'any')
.send({ invitQty: '-Infinity' });
expect(res.status).toBe(400);
});
});

describe('InventoryItem tenant-enumeration defense (secure 404)', () => {
Expand Down