You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/store-server/test/override-approval.test.ts

84 lines
4.4 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill, createOverrideApproval } from '../src/repos'
/**
* SEC-2 / H4: supervisor-override approvals are server-minted, single-use tokens BOUND to
* the exact override they authorise. The client sends an `approvalId`, never an approver
* identity — commitBill re-detects the deviation from the item master, redeems the token
* only if item/kind/before/after match, and reads the approver from its own row. These
* tests drive commitBill against a hermetic in-memory DB (seed users: u1 Ramesh/cashier,
* u2 Suresh/supervisor). Surf Excel (103) has master sale 14500, MRP 15000.
*/
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
const surfId = (db: DB): string =>
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='103'").get() as { id: string }).id
/** A bill that sells Surf Excel at 12000 (a deviation from the 14500 master), overridden. */
const billWith = (db: DB, approvalId: string): Parameters<typeof commitBill>[1] => {
const itemId = surfId(db)
return {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10',
lines: [{ itemId, name: 'Surf Excel 1kg', hsn: '3402', qty: 1, unitCode: 'PCS', unitPricePaise: 12_000, priceIncludesTax: true, taxClassCode: 'GST18' }],
payments: [{ mode: 'CASH', amountPaise: 12_000 }],
clientPayablePaise: 12_000,
overrides: [{ itemId, kind: 'price', before: 14_500, after: 12_000, approvalId }],
}
}
const priceCtx = (db: DB) => ({ itemId: surfId(db), kind: 'price' as const, beforePaise: 14_500, afterPaise: 12_000 })
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
describe('SEC-2 / H4 bound override approval tokens', () => {
it('rejects a commit whose override carries a fabricated approvalId — nothing is committed', () => {
const db = fresh()
expect(() => commitBill(db, billWith(db, '00000000-0000-7000-8000-000000000000'))).toThrow(/approval/i)
expect(billCount(db)).toBe(0)
})
it('accepts a genuine bound token, audits the SERVER-side approver, and rejects a replay', () => {
const db = fresh()
const approvalId = createOverrideApproval(db, 't1', 'u2', priceCtx(db)) // Suresh (supervisor)
const out = commitBill(db, billWith(db, approvalId))
expect(out.docNo).toMatch(/^ST1C22026-/) // 4-digit-FY prefix (M9)
const audit = db.prepare("SELECT after_json FROM audit_log WHERE action='PRICE_OVERRIDE'").get() as { after_json: string }
const after = JSON.parse(audit.after_json) as { approvedBy: string; approvedByUserId: string }
expect(after.approvedByUserId).toBe('u2') // read from the server's own row, not the client
expect(after.approvedBy).toBe('Suresh')
// token is single-use: a replay is rejected and no second bill is written
expect(() => commitBill(db, billWith(db, approvalId))).toThrow(/already used/i)
expect(billCount(db)).toBe(1)
})
it('rejects a token minted for a non-approver role (a cashier)', () => {
const db = fresh()
const cashierToken = createOverrideApproval(db, 't1', 'u1', priceCtx(db)) // u1 is a cashier
expect(() => commitBill(db, billWith(db, cashierToken))).toThrow(/not authorised/i)
expect(billCount(db)).toBe(0)
})
it('rejects an override with no approvalId at all (deviation unauthorised)', () => {
const db = fresh()
const itemId = surfId(db)
const input = billWith(db, 'placeholder')
input.overrides = [{ itemId, kind: 'price', before: 14_500, after: 12_000 }]
expect(() => commitBill(db, input)).toThrow(/supervisor approval|E-1302/i)
expect(billCount(db)).toBe(0)
})
it('H4: a token minted for item A cannot be redirected to item B', () => {
const db = fresh()
// Mint a genuine supervisor token bound to a DIFFERENT item (105 Maggi 14000→10000)...
const maggiId = (db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='105'").get() as { id: string }).id
const strayToken = createOverrideApproval(db, 't1', 'u2', { itemId: maggiId, kind: 'price', beforePaise: 14_000, afterPaise: 10_000 })
// ...and try to spend it on the Surf Excel deviation. The bound item mismatches → reject.
expect(() => commitBill(db, billWith(db, strayToken))).toThrow(/does not match|not recognised|approval/i)
expect(billCount(db)).toBe(0)
})
})