/** * Unit tests for AuditVerificationService — audit chain integrity verification. * * Tests: * 1. Intact chain: correct hashes → { verified: true, checkedCount: N, brokenAtEventId: null } * 2. Tampered chain: one wrong hash → { verified: false, brokenAtEventId: } * 3. Empty log: no rows → { verified: true, checkedCount: 0, brokenAtEventId: null } * 4. Date range params are propagated to SQL query * 5. previous_hash mismatch is detected */ import crypto from 'crypto'; import { Pool } from 'pg'; import { AuditVerificationService, IChainVerificationResult, _resetAuditVerificationServiceSingleton, getAuditVerificationService, } from '../../../src/services/AuditVerificationService'; // ============================================================================ // Helpers // ============================================================================ /** * Computes the SHA-256 hash of an audit event — must match the algorithm in * AuditVerificationService and AuditRepository. */ function computeHash( eventId: string, timestamp: Date, action: string, outcome: string, agentId: string, organizationId: string, previousHash: string, ): string { return crypto .createHash('sha256') .update( eventId + timestamp.toISOString() + action + outcome + agentId + organizationId + previousHash, ) .digest('hex'); } /** Generates a minimal audit chain row with correct hash linkage. */ function makeRow( eventId: string, timestamp: Date, action: string, outcome: string, agentId: string, organizationId: string, previousHash: string, ) { const hash = computeHash(eventId, timestamp, action, outcome, agentId, organizationId, previousHash); return { event_id: eventId, timestamp, action, outcome, agent_id: agentId, organization_id: organizationId, hash, previous_hash: previousHash, }; } /** Creates a mock pg.Pool whose query() returns the given rows. */ function mockPool(rows: unknown[]): Pool { return { query: jest.fn().mockResolvedValue({ rows }), } as unknown as Pool; } // ============================================================================ // Test data // ============================================================================ const ORG = 'org_test'; const AGENT = 'agent-abc-123'; const T1 = new Date('2026-03-01T10:00:00.000Z'); const T2 = new Date('2026-03-01T10:01:00.000Z'); const T3 = new Date('2026-03-01T10:02:00.000Z'); // ============================================================================ // Tests // ============================================================================ describe('AuditVerificationService', () => { afterEach(() => { _resetAuditVerificationServiceSingleton(); }); // ── Intact chain ────────────────────────────────────────────────────────── it('should return verified: true for an intact 3-event chain', async () => { const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, ''); const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash); const row3 = makeRow('evt-003', T3, 'token.issued', 'success', AGENT, ORG, row2.hash); const pool = mockPool([row1, row2, row3]); const service = new AuditVerificationService(pool); const result: IChainVerificationResult = await service.verifyChain(); expect(result.verified).toBe(true); expect(result.checkedCount).toBe(3); expect(result.brokenAtEventId).toBeNull(); }); it('should return verified: true for a single-event chain', async () => { const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, ''); const pool = mockPool([row1]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(true); expect(result.checkedCount).toBe(1); expect(result.brokenAtEventId).toBeNull(); }); // ── Empty log ───────────────────────────────────────────────────────────── it('should return verified: true with checkedCount 0 for an empty log', async () => { const pool = mockPool([]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(true); expect(result.checkedCount).toBe(0); expect(result.brokenAtEventId).toBeNull(); }); // ── Tampered hash ───────────────────────────────────────────────────────── it('should detect a tampered hash on the second event', async () => { const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, ''); const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash); // Tamper: replace hash on row2 with garbage const tamperedRow2 = { ...row2, hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' }; const pool = mockPool([row1, tamperedRow2]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(false); expect(result.brokenAtEventId).toBe('evt-002'); expect(result.checkedCount).toBe(1); // row1 was checked before break detected }); it('should detect a previous_hash mismatch', async () => { const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, ''); // row2 references wrong previous_hash const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, 'wrongprevhash'); const pool = mockPool([row1, row2]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(false); expect(result.brokenAtEventId).toBe('evt-002'); }); it('should stop at the first break and not report subsequent events', async () => { const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, ''); const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash); const row3 = makeRow('evt-003', T3, 'token.issued', 'success', AGENT, ORG, row2.hash); // Tamper row2 hash const tamperedRow2 = { ...row2, hash: 'aaaa' + row2.hash.slice(4) }; const pool = mockPool([row1, tamperedRow2, row3]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(false); expect(result.brokenAtEventId).toBe('evt-002'); // row3 was never checked }); // ── Pre-migration rows (empty hashes) ───────────────────────────────────── it('should skip pre-migration rows with empty hashes', async () => { // Simulate rows written before migration 020 (hash = '', previous_hash = '') const legacyRow = { event_id: 'evt-legacy', timestamp: T1, action: 'agent.created', outcome: 'success', agent_id: AGENT, organization_id: ORG, hash: '', previous_hash: '', }; const pool = mockPool([legacyRow]); const service = new AuditVerificationService(pool); const result = await service.verifyChain(); expect(result.verified).toBe(true); expect(result.checkedCount).toBe(1); expect(result.brokenAtEventId).toBeNull(); }); // ── Date range params ───────────────────────────────────────────────────── it('should propagate fromDate and toDate to the SQL query', async () => { const pool = mockPool([]); const service = new AuditVerificationService(pool); const fromDate = '2026-03-01T00:00:00.000Z'; const toDate = '2026-03-31T23:59:59.999Z'; const result = await service.verifyChain(fromDate, toDate); // Verify the query was called with date params const queryMock = pool.query as jest.Mock; expect(queryMock).toHaveBeenCalledTimes(1); const callArgs = queryMock.mock.calls[0] as [string, unknown[]]; expect(callArgs[0]).toContain('timestamp >='); expect(callArgs[0]).toContain('timestamp <='); expect(callArgs[1]).toEqual([new Date(fromDate), new Date(toDate)]); // fromDate/toDate are echoed back in result expect(result.fromDate).toBe(fromDate); expect(result.toDate).toBe(toDate); }); it('should include only fromDate in query when toDate is omitted', async () => { const pool = mockPool([]); const service = new AuditVerificationService(pool); const fromDate = '2026-03-01T00:00:00.000Z'; const result = await service.verifyChain(fromDate, undefined); const queryMock = pool.query as jest.Mock; const callArgs = queryMock.mock.calls[0] as [string, unknown[]]; expect(callArgs[0]).toContain('timestamp >='); expect(callArgs[0]).not.toContain('timestamp <='); expect(result.fromDate).toBe(fromDate); expect(result.toDate).toBeUndefined(); }); it('should include no WHERE clause when no date range is provided', async () => { const pool = mockPool([]); const service = new AuditVerificationService(pool); await service.verifyChain(); const queryMock = pool.query as jest.Mock; const callArgs = queryMock.mock.calls[0] as [string, unknown[]]; expect(callArgs[0]).not.toContain('WHERE'); expect(callArgs[1]).toEqual([]); }); // ── Singleton ───────────────────────────────────────────────────────────── it('getAuditVerificationService should return the same instance on repeated calls', () => { const pool = mockPool([]); const instance1 = getAuditVerificationService(pool); const instance2 = getAuditVerificationService(pool); expect(instance1).toBe(instance2); }); });