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/draft-guard.test.ts

208 lines
9.9 KiB
TypeScript

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<Harness> {
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<string> {
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)
})
})