Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
127 lines
4.1 KiB
JavaScript
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);
|
|
});
|
|
});
|