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 tenant’s tamper does not fail another tenant’s 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 }) }) })