hazards.test.ts
Language TypeScript8.5 KB248 lines
/*** Tests for the three silent-failure modes documented in docs/ARCHITECTURE.md.* Sample values follow the shapes seen in live responses.*/import { describe, expect, it } from 'vitest'import {assertKnownParams,assertReadMethod,FORBIDDEN_METHODS,GET_ORDERS_PARAMS,PlinthForbiddenMethodError,READ_METHODS,isEmptyOrInvalid,num,PlinthUnknownParamError,phpCollection,readError,str,toPlinthDate,} from '../src/hazards.js'import { DROPPED_FIELDS, projectOrder, projectProduct } from '../src/projection.js'import { PLINTH_RATE_LIMIT } from '../src/index.js'describe('read-only enforcement', () => {it('refuses the write methods by name', () => {// Plinth dispatches by query param, so HTTP verb enforces nothing. A GET with// method=orders.setStatus writes to production and can email customers.expect(() => assertReadMethod('orders.setStatus')).toThrow(PlinthForbiddenMethodError)expect(() => assertReadMethod('inventory.adjust')).toThrow(PlinthForbiddenMethodError)})it('says why, including the customer-email risk', () => {expect(() => assertReadMethod('orders.setStatus')).toThrow(/email customers/)})it('refuses unknown methods too, not just the known-bad ones', () => {expect(() => assertReadMethod('setSomethingNew')).toThrow(PlinthForbiddenMethodError)expect(() => assertReadMethod('')).toThrow(PlinthForbiddenMethodError)})it('allows every read method', () => {for (const m of READ_METHODS) expect(() => assertReadMethod(m)).not.toThrow()})it('has no write method on the allowlist', () => {for (const w of FORBIDDEN_METHODS) {expect(READ_METHODS as readonly string[]).not.toContain(w)}})it('is not configurable: no env var can widen it', () => {// Guards against someone adding an PLINTH_ALLOW_WRITES escape hatch later.const source = assertReadMethod.toString() + READ_METHODS.join(',')expect(source).not.toMatch(/process\.env|ALLOW_WRITE/i)})})describe('fail-open parameters', () => {it('refuses a param Plinth would silently drop', () => {// The documented trap: phone on customers.fetch returns the FULL// unfiltered list rather than erroring.expect(() => assertKnownParams('orders.fetch', { phone: '555' }, GET_ORDERS_PARAMS)).toThrow(PlinthUnknownParamError,)})it('explains why, since the failure is invisible otherwise', () => {expect(() => assertKnownParams('orders.fetch', { order_number: 'MD2639777' }, GET_ORDERS_PARAMS)).toThrow(/UNFILTERED/,)})it('allows every documented param', () => {expect(() =>assertKnownParams('orders.fetch',{ order_ref: 38794, status_code: '121', since: '20260801', limit: '50' },GET_ORDERS_PARAMS,),).not.toThrow()})it('ignores undefined values rather than rejecting them', () => {expect(() => assertKnownParams('orders.fetch', { customer_ref: undefined }, GET_ORDERS_PARAMS)).not.toThrow()})})describe('PHP collection type switching', () => {it('reads a populated collection keyed 1..N', () => {expect(phpCollection({ '1': { id: 'a' }, '2': { id: 'b' } })).toEqual([{ id: 'a' }, { id: 'b' }])})it('reads an empty collection sent as an array', () => {expect(phpCollection([])).toEqual([])})it('handles both forms in one response, which Plinth does', () => {const order = { lines: { '1': { p: 1 }, '2': { p: 2 } }, shipments: [] }expect(phpCollection(order.lines)).toHaveLength(2)expect(phpCollection(order.shipments)).toHaveLength(0)})it('survives null and undefined', () => {expect(phpCollection(null)).toEqual([])expect(phpCollection(undefined)).toEqual([])})})describe('unstable numeric typing', () => {it('coerces the string and number forms of the same field', () => {// Real values: tax_total "0.000000" and "0.00"; ship_weight 2.72 vs// unit_weight "2.724480".expect(num('0.000000')).toBe(0)expect(num('2.724480')).toBeCloseTo(2.72448)expect(num(2.72)).toBe(2.72)})it('prevents the string-concatenation bug', () => {const a = '1450'const b = 162.3375// @ts-expect-error demonstrating the bug this exists to preventexpect(a + b).toBe('1450162.3375')expect(num(a) + num(b)).toBeCloseTo(1612.3375)})it('falls back rather than producing NaN', () => {expect(num('')).toBe(0)expect(num(null)).toBe(0)expect(num('not a number', -1)).toBe(-1)})it('treats null and empty string alike, as Plinth does', () => {expect(str(null)).toBeNull()expect(str('')).toBeNull()expect(str(' ')).toBeNull()expect(str('Northline Print (orders@northlineprint.example)')).toBe('Northline Print (orders@northlineprint.example)')})})describe('date format', () => {it('converts ISO to yyyymmdd', () => {expect(toPlinthDate('2026-08-09')).toBe('20260809')})it('puts the month before the day', () => {// yyyyddmm would silently return a different window for any day <= 12.expect(toPlinthDate('2026-08-09')).not.toBe('20260908')})it('rejects anything that is not ISO rather than sending garbage', () => {expect(() => toPlinthDate('09/08/2026')).toThrow()})})describe('ambiguous errors', () => {it('recognizes that "Invalid request" also means no results', () => {expect(isEmptyOrInvalid({ error: 'Invalid request' })).toBe(true)})it('reads both error shapes', () => {expect(readError({ error: 'Bad since date' })).toBe('Bad since date')expect(readError({ notice: 'No match' })).toBe('No match')})it('does not treat a real result as an error', () => {expect(readError({ order_map: {} })).toBeNull()expect(isEmptyOrInvalid({ order_map: {} })).toBe(false)})it('does not classify rate limiting as empty', () => {// This one IS transient and should be retried; "Invalid request" must not be.expect(isEmptyOrInvalid({ error: 'Too many requests. Slow down and retry.' })).toBe(false)})})describe('projection: internal tool, per the data-handling rule, not the Northgate rules', () => {const order = {order_ref: 38794,status_label: 'On Press',grand_total: 1843.275,first_name: 'Jordan',last_name: 'Hale',company_name: 'Harbor Signs',email: 'Jordan.Hale@harborsigns.example',phone: '5550100123',bill_street: '100 Example Way',ship_postcode: '00000',}it('keeps names and companies, which make a row readable', () => {const p = projectOrder(order)expect(p.first_name).toBe('Jordan')expect(p.company_name).toBe('Harbor Signs')})it('drops contact details and addresses no panel renders', () => {const p = projectOrder(order)expect(p).not.toHaveProperty('email')expect(p).not.toHaveProperty('phone')expect(p).not.toHaveProperty('bill_street')expect(p).not.toHaveProperty('ship_postcode')})it('keeps the operational fields', () => {const p = projectOrder(order)expect(p.order_ref).toBe(38794)expect(p.status_label).toBe('On Press')expect(p.grand_total).toBeCloseTo(1843.275)})it('drops the artwork URL blob for size, not sensitivity', () => {const product = {line_ref: 51206,line_status: 'At Partner Shop',item_name: '10 ft SEG Backdrop',printfile_info: { own_artwork: { PROOF_BACK: 'https://...' } },}const p = projectProduct(product)expect(p.line_status).toBe('At Partner Shop')expect(p).not.toHaveProperty('printfile_info')expect(DROPPED_FIELDS.artwork).toContain('printfile_info')})})describe('rate limit', () => {it('throttles below what Plinth enforces', () => {const perWindow = PLINTH_RATE_LIMIT.refillPerSecond * PLINTH_RATE_LIMIT.documentedLimit.perSecondsexpect(perWindow).toBe(9)expect(perWindow).toBeLessThan(PLINTH_RATE_LIMIT.documentedLimit.requests)})it('bursts no higher than the sustained window allows', () => {expect(PLINTH_RATE_LIMIT.capacity).toBeLessThan(PLINTH_RATE_LIMIT.documentedLimit.requests)})it('is slow enough that a fan-out design would be unusable', () => {// 300 per-order detail calls would take ~5.6 minutes. This test exists to// make that concrete: page in bulk, never fan out.const secondsFor300 = 300 / PLINTH_RATE_LIMIT.refillPerSecondexpect(secondsFor300).toBeGreaterThan(300)})it('is fast enough for a paged sweep inside a 15-minute cron', () => {const secondsFor12Calls = 12 / PLINTH_RATE_LIMIT.refillPerSecondexpect(secondsFor12Calls).toBeLessThan(30)})})