Implements all 22 WS6 tasks completing Phase 3 Enterprise. Column-level encryption (AES-256-CBC, Vault-backed key) via EncryptionService applied to credentials.secret_hash, credentials.vault_path, webhook_subscriptions.vault_secret_path, and agent_did_keys.vault_key_path. Backward-compatible: isEncrypted() guard skips decryption for existing plaintext rows until next read-write cycle. Audit chain integrity (CC7.2): AuditRepository computes SHA-256 Merkle hash on every INSERT (hash = SHA-256(eventId+timestamp+action+outcome+agentId+orgId+prevHash)). AuditVerificationService walks the full chain verifying hash continuity. AuditChainVerificationJob runs hourly; sets agentidp_audit_chain_integrity Prometheus gauge to 1 (pass) or 0 (fail). TLS enforcement (CC6.7): TLSEnforcementMiddleware registered as first middleware in Express stack; 301 redirect on non-https X-Forwarded-Proto in production. SecretsRotationJob (CC9.2): hourly scan for credentials expiring within 7 days; increments agentidp_credentials_expiring_soon_total. ComplianceController + routes: GET /audit/verify (auth+audit:read scope, 30/min rate-limit); GET /compliance/controls (public, Cache-Control 60s). ComplianceStatusStore: module-level map updated by jobs, consumed by controller. Prometheus: 2 new metrics (agentidp_credentials_expiring_soon_total, agentidp_audit_chain_integrity); 6 alerting rules in alerts.yml. Compliance docs: soc2-controls-matrix.md, encryption-runbook.md, audit-log-runbook.md, incident-response.md, secrets-rotation.md. Tests: 557 unit tests passing (35 suites); 26 new tests (EncryptionService, AuditVerificationService); 19 compliance integration tests. TypeScript clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
|
* 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: <event_id> }
|
|
* 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);
|
|
});
|
|
});
|