import { afterEach, beforeEach, describe, expect, it } from 'vitest' import express from 'express' import type { AddressInfo } from 'node:net' import type { Server } from 'node:http' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { apiRouter } from '../src/api' import { commitBill, createItem } from '../src/repos' /** * Cashier draft-item quick-add guards (red-team follow-up: the draft path reproduced C2/C3). * A cashier (BILL_CREATE, no MASTER_EDIT) could POST /items status:'draft' with an arbitrary * price AND an arbitrary taxClassCode, then bill it with the server anchoring to that just- * authored master — arbitrary price + GST evasion (ZERO tax on a taxable good) with no * deviation audit row. These tests drive REAL HTTP against a mounted apiRouter (the exact * surface of the hole) plus commitBill directly for the audit-row assertion. * * Seed users: u1 Ramesh/cashier (PIN 4728), u4 Divya/cashier (PIN 3591), u3 Thomas/owner * (PIN 9174, MASTER_EDIT). Store s1, counter c2. Surf Excel (103) master sale 14500, GST18. */ interface Harness { db: DB; url: string; server: Server } async function start(): Promise { const db = openDb(':memory:') seedIfEmpty(db) const app = express() app.use(express.json()) app.use('/api', apiRouter(db)) // Mirror server.ts's generic error handler so tagged throws surface as { error, code }. app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const e = (err ?? {}) as { status?: number; code?: string; message?: string } const status = typeof e.status === 'number' ? e.status : 500 if (res.headersSent) return res.status(status).json({ error: status >= 500 ? 'Something went wrong' : (e.message ?? 'Bad request'), code: e.code ?? 'E-5000' }) }) const server: Server = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)) }) const port = (server.address() as AddressInfo).port return { db, url: `http://127.0.0.1:${port}`, server } } async function login(url: string, userId: string, pin: string): Promise { const res = await fetch(`${url}/api/auth/pin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ userId, pin }), }) const body = (await res.json()) as { token?: string } if (body.token === undefined) throw new Error(`login failed for ${userId}`) return body.token } const post = (url: string, path: string, token: string, body: unknown) => fetch(`${url}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body), }) const get = (url: string, path: string, token: string) => fetch(`${url}${path}`, { headers: { authorization: `Bearer ${token}` } }) const itemIdOf = (db: DB, code: string): string => (db.prepare(`SELECT id FROM item WHERE tenant_id='t1' AND code=?`).get(code) as { id: string }).id const sellLine = (itemId: string, price: number) => ({ itemId, name: 'x', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: price, priceIncludesTax: true, taxClassCode: 'GST18' }) let h: Harness beforeEach(async () => { h = await start() }) afterEach(() => { h.server.close() }) describe('cashier draft tax-class forcing (C3)', () => { it('IGNORES a cashier-chosen ZERO tax class and assigns the store default (GST18)', async () => { const token = await login(h.url, 'u1', '4728') const res = await post(h.url, '/api/items', token, { code: 'RT-WHISKEY', name: 'Redteam Whiskey', taxClassCode: 'ZERO', salePricePaise: 200_000, status: 'draft', }) expect(res.status).toBe(200) const item = (await res.json()) as { taxClassCode: string; status: string } expect(item.taxClassCode).toBe('GST18') // server default, NOT the client's ZERO expect(item.status).toBe('draft') }) it('lets a MASTER_EDIT role (owner) keep its chosen tax class', async () => { const token = await login(h.url, 'u3', '9174') // owner const res = await post(h.url, '/api/items', token, { code: 'OWN-ZERO', name: 'Owner Zero Good', taxClassCode: 'ZERO', salePricePaise: 5_000, status: 'draft', }) expect(res.status).toBe(200) const item = (await res.json()) as { taxClassCode: string } expect(item.taxClassCode).toBe('ZERO') // owner's explicit choice honoured }) it('a ZERO-forged draft, once billed, is taxed at the forced GST18 (GST evasion closed)', async () => { const token = await login(h.url, 'u1', '4728') const created = await (await post(h.url, '/api/items', token, { code: 'RT-TAXED', name: 'Redteam Taxed Good', taxClassCode: 'ZERO', salePricePaise: 10_000, status: 'draft', })).json() as { id: string } const bill = await post(h.url, '/api/bills', token, { storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13', lines: [sellLine(created.id, 10_000)], payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000, }) expect(bill.status).toBe(200) const row = h.db.prepare(`SELECT cgst_paise, sgst_paise FROM bill LIMIT 1`).get() as { cgst_paise: number; sgst_paise: number } expect(row.cgst_paise + row.sgst_paise).toBeGreaterThan(0) // real GST charged, not ₹0 }) }) describe('cashier draft price cap (C2 bound)', () => { it('rejects a draft priced above the ₹5,000 cap with E-1108', async () => { const token = await login(h.url, 'u1', '4728') const res = await post(h.url, '/api/items', token, { code: 'RT-EXP', name: 'Expensive Unknown', taxClassCode: 'GST18', salePricePaise: 999_900, status: 'draft', // ₹9,999 > ₹5,000 }) expect(res.status).toBe(400) const body = (await res.json()) as { code: string } expect(body.code).toBe('E-1108') expect(h.db.prepare(`SELECT COUNT(*) n FROM item WHERE code='RT-EXP'`).get()).toMatchObject({ n: 0 }) }) it('allows a reasonable draft under the cap', async () => { const token = await login(h.url, 'u1', '4728') const res = await post(h.url, '/api/items', token, { code: 'RT-OK', name: 'Reasonable Unknown', taxClassCode: 'GST18', salePricePaise: 45_000, status: 'draft', // ₹450 < ₹5,000 }) expect(res.status).toBe(200) }) it('lets a MASTER_EDIT role (owner) create above the cap', async () => { const token = await login(h.url, 'u3', '9174') // owner const res = await post(h.url, '/api/items', token, { code: 'OWN-EXP', name: 'Owner Expensive', taxClassCode: 'GST18', salePricePaise: 999_900, status: 'draft', }) expect(res.status).toBe(200) }) }) describe('draft sale is audited DRAFT_SALE', () => { it('writes one DRAFT_SALE row (itemId, name, price) in the commit transaction', () => { // Direct repos: a draft item sold at its own price is not a deviation, so absent this // flag the only trace would be ITEM_CREATE — prove the explicit sale trail exists. const draft = createItem(h.db, 't1', { code: 'RT-DRAFT', name: 'Redteam Draft', hsn: '', taxClassCode: 'GST18', unitCode: 'PCS', unitDecimals: 0, salePricePaise: 10_000, status: 'draft', }) commitBill(h.db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-13', lines: [{ itemId: draft.id, name: draft.name, hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 10_000, priceIncludesTax: true, taxClassCode: 'GST18' }], payments: [{ mode: 'CASH', amountPaise: 10_000 }], clientPayablePaise: 10_000, }) const audit = h.db.prepare(`SELECT after_json FROM audit_log WHERE action='DRAFT_SALE'`).get() as { after_json: string } | undefined expect(audit).toBeDefined() const after = JSON.parse(audit!.after_json) as { itemId: string; name: string; pricePaise: number } expect(after.itemId).toBe(draft.id) expect(after.name).toBe('Redteam Draft') expect(after.pricePaise).toBe(10_000) }) it('an active (non-draft) item sale writes NO DRAFT_SALE row', () => { const surf = itemIdOf(h.db, '103') commitBill(h.db, { tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-13', lines: [{ itemId: surf, name: 'Surf', hsn: '', qty: 1, unitCode: 'PCS', unitPricePaise: 14_500, priceIncludesTax: true, taxClassCode: 'GST18' }], payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500, }) expect(h.db.prepare(`SELECT COUNT(*) n FROM audit_log WHERE action='DRAFT_SALE'`).get()).toMatchObject({ n: 0 }) }) }) describe('GET /bills is scoped for a cashier (cross-counter read residual)', () => { it('a cashier sees only their OWN bills; an owner (REPORT_VIEW) sees all', async () => { const surf = itemIdOf(h.db, '103') const t1 = await login(h.url, 'u1', '4728') // Ramesh const t4 = await login(h.url, 'u4', '3591') // Divya const billBody = { storeId: 's1', counterId: 'c2', shiftId: 'sh1', businessDate: '2026-07-13', lines: [sellLine(surf, 14_500)], payments: [{ mode: 'CASH', amountPaise: 14_500 }], clientPayablePaise: 14_500, } expect((await post(h.url, '/api/bills', t1, billBody)).status).toBe(200) // Ramesh's bill expect((await post(h.url, '/api/bills', t4, billBody)).status).toBe(200) // Divya's bill const rameshView = (await (await get(h.url, '/api/bills', t1)).json()) as { cashier_id: string }[] expect(rameshView.length).toBe(1) expect(rameshView.every((b) => b.cashier_id === 'u1')).toBe(true) const divyaView = (await (await get(h.url, '/api/bills', t4)).json()) as { cashier_id: string }[] expect(divyaView.length).toBe(1) expect(divyaView.every((b) => b.cashier_id === 'u4')).toBe(true) // Owner has REPORT_VIEW → full cross-counter/cashier view (both bills). const owner = await login(h.url, 'u3', '9174') const ownerView = (await (await get(h.url, '/api/bills', owner)).json()) as unknown[] expect(ownerView.length).toBe(2) }) })