/** * Integration tests for compliance API endpoints. * * Tests: * 1. GET /compliance/controls returns 200 with 5 controls * 2. GET /audit/verify with audit:read token returns 200 * 3. GET /audit/verify without token returns 401 * 4. GET /audit/verify with invalid fromDate returns 400 VALIDATION_ERROR * 5. GET /audit/verify with fromDate > toDate returns 400 VALIDATION_ERROR */ import crypto from 'crypto'; import request from 'supertest'; import express, { Application } from 'express'; import { v4 as uuidv4 } from 'uuid'; // ============================================================================ // Environment setup — must be before any app imports // ============================================================================ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); process.env['NODE_ENV'] = 'test'; process.env['JWT_PRIVATE_KEY'] = privateKey; process.env['JWT_PUBLIC_KEY'] = publicKey; // ============================================================================ // Mock Redis — authMiddleware calls getRedisClient() for revocation check. // Return a mock client that says no tokens are revoked. // ============================================================================ jest.mock('../../../src/cache/redis', () => ({ getRedisClient: jest.fn().mockResolvedValue({ get: jest.fn().mockResolvedValue(null), // no tokens revoked set: jest.fn().mockResolvedValue('OK'), incr: jest.fn().mockResolvedValue(1), expire: jest.fn().mockResolvedValue(1), }), closeRedisClient: jest.fn().mockResolvedValue(undefined), })); // ============================================================================ // Minimal app that wires only compliance routes (avoids full DB dependency) // ============================================================================ import { createComplianceRouter } from '../../../src/routes/compliance'; import { ComplianceController } from '../../../src/controllers/ComplianceController'; import { AuditVerificationService } from '../../../src/services/AuditVerificationService'; import { Pool } from 'pg'; import { errorHandler } from '../../../src/middleware/errorHandler'; import { signToken } from '../../../src/utils/jwt'; import { _resetAuditVerificationServiceSingleton } from '../../../src/services/AuditVerificationService'; // ============================================================================ // Helpers // ============================================================================ /** Creates a JWT token with the given scope. */ function makeToken(scope: string = 'audit:read'): string { const agentId = uuidv4(); return signToken({ sub: agentId, client_id: agentId, scope, jti: uuidv4() }, privateKey); } /** Creates a minimal Express app with compliance routes only. */ function createMinimalApp(mockPool: Pool): Application { const app = express(); app.use(express.json()); const auditVerificationService = new AuditVerificationService(mockPool); const complianceController = new ComplianceController(auditVerificationService); app.use('/api/v1', createComplianceRouter(complianceController)); app.use(errorHandler); return app; } /** Creates a mock Pool that returns empty rows for any query. */ function makeEmptyPool(): Pool { return { query: jest.fn().mockResolvedValue({ rows: [] }), } as unknown as Pool; } // ============================================================================ // Tests // ============================================================================ describe('Compliance Endpoints Integration Tests', () => { let app: Application; let mockPool: Pool; beforeEach(() => { _resetAuditVerificationServiceSingleton(); mockPool = makeEmptyPool(); app = createMinimalApp(mockPool); }); afterEach(() => { _resetAuditVerificationServiceSingleton(); }); // ── GET /compliance/controls ────────────────────────────────────────────── describe('GET /api/v1/compliance/controls', () => { it('should return 200 with exactly 5 controls', async () => { const res = await request(app).get('/api/v1/compliance/controls'); expect(res.status).toBe(200); expect(res.body).toHaveProperty('controls'); expect(Array.isArray(res.body.controls)).toBe(true); expect(res.body.controls).toHaveLength(5); }); it('should include all required control IDs', async () => { const res = await request(app).get('/api/v1/compliance/controls'); expect(res.status).toBe(200); const ids = (res.body.controls as Array<{ id: string }>).map((c) => c.id); expect(ids).toContain('CC6.1'); expect(ids).toContain('CC6.7'); expect(ids).toContain('CC7.2'); expect(ids).toContain('CC9.2'); expect(ids).toContain('CC7.1'); }); it('should include required fields on each control', async () => { const res = await request(app).get('/api/v1/compliance/controls'); expect(res.status).toBe(200); for (const control of res.body.controls as Array>) { expect(control).toHaveProperty('id'); expect(control).toHaveProperty('name'); expect(control).toHaveProperty('status'); expect(control).toHaveProperty('lastChecked'); expect(['passing', 'failing', 'unknown']).toContain(control['status']); } }); it('should set Cache-Control header', async () => { const res = await request(app).get('/api/v1/compliance/controls'); expect(res.status).toBe(200); expect(res.headers['cache-control']).toBe('public, max-age=60'); }); it('should not require authentication', async () => { // No Authorization header const res = await request(app).get('/api/v1/compliance/controls'); expect(res.status).toBe(200); }); }); // ── GET /audit/verify ───────────────────────────────────────────────────── describe('GET /api/v1/audit/verify', () => { it('should return 200 with verification result when authenticated with audit:read scope', async () => { const token = makeToken('audit:read'); const res = await request(app) .get('/api/v1/audit/verify') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body).toHaveProperty('verified'); expect(res.body).toHaveProperty('checkedCount'); expect(res.body).toHaveProperty('brokenAtEventId'); expect(typeof res.body.verified).toBe('boolean'); expect(typeof res.body.checkedCount).toBe('number'); }); it('should return 401 when no token is provided', async () => { const res = await request(app) .get('/api/v1/audit/verify'); expect(res.status).toBe(401); }); it('should return 403 when token lacks audit:read scope', async () => { const token = makeToken('agents:read'); const res = await request(app) .get('/api/v1/audit/verify') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(403); expect(res.body.code).toBe('INSUFFICIENT_SCOPE'); }); it('should return 400 VALIDATION_ERROR when fromDate is not a valid ISO 8601 date', async () => { const token = makeToken('audit:read'); const res = await request(app) .get('/api/v1/audit/verify?fromDate=not-a-date') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); expect(res.body.code).toBe('VALIDATION_ERROR'); expect(res.body.details).toHaveProperty('field', 'fromDate'); }); it('should return 400 VALIDATION_ERROR when toDate is not a valid ISO 8601 date', async () => { const token = makeToken('audit:read'); const res = await request(app) .get('/api/v1/audit/verify?toDate=2026-13-99') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); expect(res.body.code).toBe('VALIDATION_ERROR'); expect(res.body.details).toHaveProperty('field', 'toDate'); }); it('should return 400 VALIDATION_ERROR when fromDate is after toDate', async () => { const token = makeToken('audit:read'); const res = await request(app) .get('/api/v1/audit/verify?fromDate=2026-03-31T00:00:00.000Z&toDate=2026-03-01T00:00:00.000Z') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(400); expect(res.body.code).toBe('VALIDATION_ERROR'); }); it('should accept valid date range params and return 200', async () => { const token = makeToken('audit:read'); const res = await request(app) .get('/api/v1/audit/verify?fromDate=2026-03-01T00:00:00.000Z&toDate=2026-03-31T23:59:59.999Z') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.verified).toBe(true); expect(res.body.fromDate).toBe('2026-03-01T00:00:00.000Z'); expect(res.body.toDate).toBe('2026-03-31T23:59:59.999Z'); }); }); });