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.
84 lines
4.4 KiB
TypeScript
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)
|
|
})
|
|
})
|