/** * Unit tests for src/services/ComplianceService.ts */ import { Pool, QueryResult } from 'pg'; import type { RedisClientType } from 'redis'; import { ComplianceService } from '../../../src/services/ComplianceService'; // ── Mock AuditVerificationService (instantiated internally by ComplianceService) ── jest.mock('../../../src/services/AuditVerificationService', () => { return { AuditVerificationService: jest.fn().mockImplementation(() => ({ verifyChain: jest.fn().mockResolvedValue({ verified: true, checkedCount: 42, brokenAtEventId: null, }), })), }; }); // ── Re-import after mock is established ────────────────────────────────────── import { AuditVerificationService } from '../../../src/services/AuditVerificationService'; const MockAuditVerificationService = AuditVerificationService as jest.MockedClass< typeof AuditVerificationService >; // ── Mock helpers ────────────────────────────────────────────────────────────── function makePool(queryFn: jest.Mock): Pool { return { query: queryFn } as unknown as Pool; } function makeRedis(overrides: Partial<{ getFn: jest.Mock; setFn: jest.Mock; }>): RedisClientType { return { get: overrides.getFn ?? jest.fn().mockResolvedValue(null), set: overrides.setFn ?? jest.fn().mockResolvedValue('OK'), } as unknown as RedisClientType; } // ════════════════════════════════════════════════════════════════════════════ // ComplianceService // ════════════════════════════════════════════════════════════════════════════ describe('ComplianceService', () => { beforeEach(() => { jest.clearAllMocks(); // Reset AuditVerificationService mock to default passing behaviour MockAuditVerificationService.mockImplementation( () => ({ verifyChain: jest.fn().mockResolvedValue({ verified: true, checkedCount: 42, brokenAtEventId: null, }), }) as unknown as InstanceType, ); }); // ──────────────────────────────────────────────────────────────── // generateReport() — cache miss // ──────────────────────────────────────────────────────────────── describe('generateReport() — cache miss', () => { it('should build a report, store it in Redis, and return IComplianceReport structure', async () => { // Cache miss → null const getFn = jest.fn().mockResolvedValue(null); const setFn = jest.fn().mockResolvedValue('OK'); // Pool returns empty agents list → agent-identity section passes trivially const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn })); const report = await service.generateReport('tenant-1'); // Redis cache miss check expect(getFn).toHaveBeenCalledWith('compliance:report:tenant-1'); // Report stored in Redis after build expect(setFn).toHaveBeenCalledWith( 'compliance:report:tenant-1', expect.any(String), expect.objectContaining({ EX: 300 }), ); // IComplianceReport structure expect(report).toMatchObject({ tenant_id: 'tenant-1', agntcy_schema_version: '1.0', sections: expect.any(Array), overall_status: expect.stringMatching(/^(pass|fail|warn)$/), generated_at: expect.any(String), }); // from_cache should be absent on a freshly built report expect(report.from_cache).toBeUndefined(); }); }); // ──────────────────────────────────────────────────────────────── // generateReport() — cache hit // ──────────────────────────────────────────────────────────────── describe('generateReport() — cache hit', () => { it('should return cached data with from_cache: true', async () => { const cachedReport = { generated_at: '2026-04-04T00:00:00.000Z', tenant_id: 'tenant-cache', agntcy_schema_version: '1.0', sections: [{ name: 'agent-identity', status: 'pass', details: 'All good.' }], overall_status: 'pass', }; const getFn = jest.fn().mockResolvedValue(JSON.stringify(cachedReport)); const setFn = jest.fn(); const mockQuery = jest.fn(); const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn })); const report = await service.generateReport('tenant-cache'); expect(report.from_cache).toBe(true); expect(report.tenant_id).toBe('tenant-cache'); expect(report.overall_status).toBe('pass'); // No DB queries should be made on a cache hit expect(mockQuery).not.toHaveBeenCalled(); // Redis set should not be called either expect(setFn).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────────────────────── // generateReport() — overall_status rollup // ──────────────────────────────────────────────────────────────── describe('generateReport() — overall_status', () => { it('should return overall_status: pass when all sections pass', async () => { const getFn = jest.fn().mockResolvedValue(null); const setFn = jest.fn().mockResolvedValue('OK'); // No agents → agent-identity passes trivially // AuditVerificationService mock returns verified: true (default in beforeEach) const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn })); const report = await service.generateReport('tenant-all-pass'); expect(report.overall_status).toBe('pass'); }); it('should return overall_status: fail when any section fails', async () => { const getFn = jest.fn().mockResolvedValue(null); const setFn = jest.fn().mockResolvedValue('OK'); // Override AuditVerificationService to simulate broken chain → audit-trail fails MockAuditVerificationService.mockImplementation( () => ({ verifyChain: jest.fn().mockResolvedValue({ verified: false, checkedCount: 10, brokenAtEventId: 'event-uuid-broken', }), }) as unknown as InstanceType, ); // No agents so agent-identity is 'pass'; audit-trail will be 'fail' const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn })); const report = await service.generateReport('tenant-fail'); expect(report.overall_status).toBe('fail'); const auditSection = report.sections.find((s) => s.name === 'audit-trail'); expect(auditSection?.status).toBe('fail'); }); }); // ──────────────────────────────────────────────────────────────── // exportAgentCards() // ──────────────────────────────────────────────────────────────── describe('exportAgentCards()', () => { it('should return an array of IAgentCard objects with correct fields', async () => { const createdAt = new Date('2026-01-15T12:00:00Z'); const agentRows = [ { agent_id: 'agent-uuid-1', owner: 'team-alpha', capabilities: ['agents:read', 'tokens:issue'], created_at: createdAt, did: 'did:web:sentryagent.ai:agent-uuid-1', }, { agent_id: 'agent-uuid-2', owner: 'team-beta', capabilities: ['agents:read'], created_at: createdAt, did: null, // no DID — id should fall back to agent_id }, ]; const mockQuery = jest.fn().mockResolvedValue({ rows: agentRows, } as unknown as QueryResult); const service = new ComplianceService( makePool(mockQuery), makeRedis({ getFn: jest.fn(), setFn: jest.fn() }), ); const cards = await service.exportAgentCards('tenant-1'); expect(cards).toHaveLength(2); // Card with DID uses DID as id expect(cards[0]).toEqual({ id: 'did:web:sentryagent.ai:agent-uuid-1', name: 'team-alpha', capabilities: ['agents:read', 'tokens:issue'], endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-1', created_at: createdAt.toISOString(), agntcy_schema_version: '1.0', }); // Card without DID falls back to agent_id as id expect(cards[1]).toEqual({ id: 'agent-uuid-2', name: 'team-beta', capabilities: ['agents:read'], endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-2', created_at: createdAt.toISOString(), agntcy_schema_version: '1.0', }); }); it('should return an empty array when no active agents exist', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [], } as unknown as QueryResult); const service = new ComplianceService( makePool(mockQuery), makeRedis({ getFn: jest.fn(), setFn: jest.fn() }), ); const cards = await service.exportAgentCards('tenant-empty'); expect(cards).toEqual([]); }); it('should query only non-decommissioned agents scoped to the tenantId', async () => { const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult); const service = new ComplianceService( makePool(mockQuery), makeRedis({ getFn: jest.fn(), setFn: jest.fn() }), ); await service.exportAgentCards('tenant-scope-test'); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining("status != 'decommissioned'"), ['tenant-scope-test'], ); }); }); });