godcrm/backend/services/audit/__tests__/verifyAuditChain.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

127 lines
4.1 KiB
JavaScript

// backend/services/audit/__tests__/verifyAuditChain.test.js
//
// ADR-0066-A §C — DB wrapper around the pure verifier. The tamper logic
// itself is covered by auditChain.test.js (verifyChainRows); here we test
// cutover handling, the from_id partial-verify seed, head emission, and
// the good/tampered integration path. DB is mocked (ADR-0009).
import { describe, it, expect, vi, beforeEach } from 'vitest';
const dbGet = vi.fn();
const dbAll = vi.fn();
vi.mock('../../../database/connection.js', () => ({ dbGet, dbAll }));
const { verifyAuditChain, getCutoverId } = await import('../verifyAuditChain.js');
const { computeEntryHash } = await import('../auditChain.js');
const GENESIS = {
id: 1,
user_id: 7,
acting_as: null,
action: 'row.create',
entity_type: 'table_row',
entity_id: '12345',
details: '{"table_id":1708}',
request_id: 'req-a',
space_id: 11,
ip_addr: '203.0.113.7',
created_at: new Date('2026-08-02T10:29:44.000Z'),
};
const ROW2 = {
id: 2,
user_id: 7,
acting_as: null,
action: 'row.update',
entity_type: 'table_row',
entity_id: '12345',
details: null,
request_id: 'req-b',
space_id: 11,
ip_addr: '203.0.113.7',
created_at: new Date('2026-08-02T10:30:00.000Z'),
};
const G = computeEntryHash(null, GENESIS);
const R2 = computeEntryHash(G, ROW2);
function validRows() {
return [
{ ...GENESIS, prev_hash: null, entry_hash: G },
{ ...ROW2, prev_hash: G, entry_hash: R2 },
];
}
// dbGet resolver: distinguishes the cutover lookup from the seed lookup.
function mockDb({ cutover = 0, seed = null, rows = validRows() } = {}) {
dbGet.mockImplementation(async (sql) => {
if (/audit_chain_meta/.test(sql)) return cutover == null ? null : { value: cutover };
if (/id < \?/.test(sql)) return seed; // predecessor seed
return null;
});
dbAll.mockResolvedValue(rows);
}
beforeEach(() => {
dbGet.mockReset();
dbAll.mockReset();
});
describe('getCutoverId', () => {
it('reads the meta row', async () => {
mockDb({ cutover: 42 });
expect(await getCutoverId()).toBe(42);
});
it('defaults to 0 when the meta row is absent', async () => {
mockDb({ cutover: null });
expect(await getCutoverId()).toBe(0);
});
});
describe('verifyAuditChain', () => {
it('returns ok:true with checked count, cutover, and head on a clean chain', async () => {
mockDb({ cutover: 0 });
const res = await verifyAuditChain();
expect(res).toEqual({
ok: true,
checked: 2,
break_at_id: null,
cutover_id: 0,
head: { max_id: 2, entry_hash: R2.toString('hex') },
});
// Full verify walks strictly above cutover.
expect(dbAll).toHaveBeenCalledWith(expect.stringMatching(/id >= \?/), [1]);
});
it('returns ok:false with break_at_id when a row is tampered', async () => {
const rows = validRows();
rows[1] = { ...rows[1], action: 'row.delete' }; // stored hash no longer matches
mockDb({ cutover: 0, rows });
const res = await verifyAuditChain();
expect(res.ok).toBe(false);
expect(res.break_at_id).toBe(2);
expect(res.checked).toBe(1);
});
it('excludes pre-cutover rows (walks id > cutover only)', async () => {
mockDb({ cutover: 5, rows: [] });
const res = await verifyAuditChain();
expect(dbAll).toHaveBeenCalledWith(expect.stringMatching(/id >= \?/), [6]);
expect(res).toMatchObject({ ok: true, checked: 0, cutover_id: 5, head: null });
});
it('seeds a partial verify from the predecessor hash (from_id)', async () => {
// Verify only row 2, seeded from genesis hash as anchor.
mockDb({ cutover: 0, seed: { entry_hash: G }, rows: [validRows()[1]] });
const res = await verifyAuditChain(2);
expect(dbAll).toHaveBeenCalledWith(expect.stringMatching(/id >= \?/), [2]);
expect(res).toMatchObject({ ok: true, checked: 1, break_at_id: null });
});
it('flags a partial verify whose first row does not link to the seed', async () => {
const wrongSeed = Buffer.from('99'.repeat(32), 'hex');
mockDb({ cutover: 0, seed: { entry_hash: wrongSeed }, rows: [validRows()[1]] });
const res = await verifyAuditChain(2);
expect(res.ok).toBe(false);
expect(res.break_at_id).toBe(2);
});
});