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/tenant-scope.test.ts

68 lines
3.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill } from '../src/repos'
/**
* M5 (red-team): commitBill's store/counter lookup is tenant-scoped — `WHERE id=? AND tenant_id=?`
* for both — and an unknown-for-tenant store or counter is rejected 400 (E-1110) BEFORE any money
* logic, which also removes the old undefined-deref 500. (E-1110, not the task's suggested E-1109,
* because E-1109 is already the "payments do not sum to payable" code — reusing it would be
* ambiguous.) The /bills and /bills/offline routes both funnel through commitBill, so both paths
* are covered. Hermetic in-memory DB; Carry Bag (108) has master sale 500, no MRP, GST18.
*/
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
const carryBag = (db: DB): string =>
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code='108'").get() as { id: string }).id
const billCount = (db: DB): number => (db.prepare('SELECT COUNT(*) n FROM bill').get() as { n: number }).n
const base = (db: DB): Omit<Parameters<typeof commitBill>[1], 'storeId'> => ({
tenantId: 't1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1', businessDate: '2026-07-10',
lines: [{ itemId: carryBag(db), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
})
const thrown = (fn: () => unknown): (Error & { status?: number; code?: string }) | undefined => {
try { fn(); return undefined } catch (e) { return e as Error & { status?: number; code?: string } }
}
describe('M5 — commitBill store/counter are tenant-scoped', () => {
it('a nonexistent storeId is rejected 400 (E-1110) — no 500, nothing committed', () => {
const db = fresh()
const err = thrown(() => commitBill(db, { ...base(db), storeId: 'does-not-exist' }))
expect(err).toBeDefined()
expect(err!.status).toBe(400)
expect(err!.code).toBe('E-1110')
expect(billCount(db)).toBe(0)
})
it('a nonexistent counterId is rejected 400 (E-1110)', () => {
const db = fresh()
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's1', counterId: 'nope' }))
expect(err!.status).toBe(400)
expect(err!.code).toBe('E-1110')
expect(billCount(db)).toBe(0)
})
it('a store/counter belonging to ANOTHER tenant is rejected (true cross-tenant scoping)', () => {
const db = fresh()
db.prepare("INSERT INTO tenant (id, code, name, gst_scheme, fy_start_month, created_at) VALUES ('t2','T2','Other','regular',4,?)").run(new Date().toISOString())
db.prepare("INSERT INTO store (id, tenant_id, code, name, state_code) VALUES ('s2','t2','ST9','Other Store','29')").run()
db.prepare("INSERT INTO counter (id, tenant_id, store_id, code, name) VALUES ('cc2','t2','s2','C1','C')").run()
// commit as tenant t1 but point at t2's store/counter → must reject, never bill against t2's store
const err = thrown(() => commitBill(db, { ...base(db), storeId: 's2', counterId: 'cc2' }))
expect(err!.code).toBe('E-1110')
expect(billCount(db)).toBe(0)
})
it('the honest same-tenant store/counter still commits (4-digit-FY doc no)', () => {
const db = fresh()
const out = commitBill(db, { ...base(db), storeId: 's1' })
expect(out.docNo).toMatch(/^ST1C22026-/)
expect(out.docNo.length).toBeLessThanOrEqual(16)
expect(billCount(db)).toBe(1)
})
})