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/audit-chain.test.ts

93 lines
4.5 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { describe, expect, it } from 'vitest'
import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { commitBill, verifyAuditChain, writeAudit } from '../src/repos'
/**
* H2 (red-team): the audit log is a per-tenant hash chain. Each row's entry_hash covers the
* previous entry's hash plus its own content, so any out-of-band UPDATE/DELETE/INSERT to the
* table breaks the chain, which verifyAuditChain / GET /api/audit/verify recomputes. This is
* tamper-EVIDENCE, not prevention (the SQLite file is still writable — sealing it is H3). These
* tests build a chain, prove it verifies, then tamper directly via better-sqlite3 and re-verify.
*/
function fresh(): DB {
const db = openDb(':memory:')
seedIfEmpty(db)
return db
}
const itemId = (db: DB, code: string): string =>
(db.prepare("SELECT id FROM item WHERE tenant_id='t1' AND code=?").get(code) as { id: string }).id
interface AuditRow { id: string; prev_hash: string | null; entry_hash: string | null }
const auditRows = (db: DB): AuditRow[] =>
db.prepare("SELECT id, prev_hash, entry_hash FROM audit_log WHERE tenant_id='t1' ORDER BY rowid").all() as AuditRow[]
describe('H2 — tamper-evident audit hash chain', () => {
it('a genuine sequence verifies, and each row links to the previous (genesis = "")', () => {
const db = fresh()
// a real commit writes a BILL_COMMIT row inside its transaction...
commitBill(db, {
tenantId: 't1', storeId: 's1', counterId: 'c2', cashierId: 'u1', shiftId: 'sh1',
businessDate: '2026-07-10',
lines: [{ itemId: itemId(db, '108'), name: 'Carry Bag', hsn: '3923', qty: 1, unitCode: 'PCS', unitPricePaise: 500, priceIncludesTax: true, taxClassCode: 'GST18' }],
payments: [{ mode: 'CASH', amountPaise: 500 }], clientPayablePaise: 500,
})
// ...and a couple of plain rows chain on after it
writeAudit(db, 't1', 'u1', 'TEST_A', 'x', 'x1')
writeAudit(db, 't1', 'u3', 'TEST_B', 'x', 'x2', { a: 1 }, { b: 2 })
const rows = auditRows(db)
expect(rows.length).toBeGreaterThanOrEqual(3)
expect(rows[0]!.prev_hash).toBe('') // genesis
for (let i = 1; i < rows.length; i++) expect(rows[i]!.prev_hash).toBe(rows[i - 1]!.entry_hash)
expect(verifyAuditChain(db, 't1')).toEqual({ ok: true })
})
it('detects an out-of-band UPDATE, pointing at the changed row', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't1', 'u1', 'B', 'e', '2')
writeAudit(db, 't1', 'u1', 'C', 'e', '3')
expect(verifyAuditChain(db, 't1').ok).toBe(true)
const target = auditRows(db)[1]!.id
db.prepare('UPDATE audit_log SET after_json=? WHERE id=?').run(JSON.stringify({ forged: true }), target)
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe(target)
})
it('detects an out-of-band DELETE, pointing at the row after the gap', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't1', 'u1', 'B', 'e', '2')
writeAudit(db, 't1', 'u1', 'C', 'e', '3')
const rows = auditRows(db)
db.prepare('DELETE FROM audit_log WHERE id=?').run(rows[1]!.id) // remove the middle row
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe(rows[2]!.id) // its prev_hash no longer matches the running chain
})
it('detects a forged INSERT appended out-of-band', () => {
const db = fresh()
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
db.prepare(
`INSERT INTO audit_log (id, tenant_id, at_wall, user_id, action, entity, entity_id, before_json, after_json, prev_hash, entry_hash)
VALUES ('forged-id', 't1', ?, 'attacker', 'FORGED', 'e', '9', NULL, ?, 'deadbeef', 'cafebabe')`,
).run(new Date().toISOString(), JSON.stringify({ evil: true }))
const res = verifyAuditChain(db, 't1')
expect(res.ok).toBe(false)
expect(res.brokenAtId).toBe('forged-id')
})
it('scopes per tenant — one tenants tamper does not fail another tenants chain', () => {
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())
writeAudit(db, 't1', 'u1', 'A', 'e', '1')
writeAudit(db, 't2', 'z9', 'A', 'e', '1')
writeAudit(db, 't2', 'z9', 'B', 'e', '2')
// tamper only t1
db.prepare('UPDATE audit_log SET action=? WHERE tenant_id=?').run('HACKED', 't1')
expect(verifyAuditChain(db, 't1').ok).toBe(false)
expect(verifyAuditChain(db, 't2')).toEqual({ ok: true })
})
})