/** * Unit tests for src/controllers/AuditController.ts * AuditService is mocked; handlers are invoked with mock req/res/next. */ import { Request, Response, NextFunction } from 'express'; import { AuditController } from '../../../src/controllers/AuditController'; import { AuditService } from '../../../src/services/AuditService'; import { ITokenPayload, IAuditEvent } from '../../../src/types/index'; import { ValidationError, AuthenticationError, AuditEventNotFoundError, } from '../../../src/utils/errors'; jest.mock('../../../src/services/AuditService'); const MockAuditService = AuditService as jest.MockedClass; // ─── helpers ───────────────────────────────────────────────────────────────── function makeUser(scope: string): ITokenPayload { return { sub: 'agent-id-001', client_id: 'agent-id-001', scope, jti: 'jti-001', iat: 1000, exp: 9999999999, }; } const MOCK_AUDIT_EVENT: IAuditEvent = { eventId: 'evt-id-001', agentId: 'agent-id-001', action: 'agent.created', outcome: 'success', ipAddress: '127.0.0.1', userAgent: 'test-agent/1.0', metadata: {}, timestamp: new Date('2026-03-28T09:00:00Z'), }; function buildMocks(scope = 'audit:read'): { req: Partial; res: Partial; next: NextFunction; } { const res: Partial = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), }; return { req: { user: makeUser(scope), body: {}, params: {}, query: {}, headers: {}, ip: '127.0.0.1', }, res, next: jest.fn() as NextFunction, }; } // ─── suite ─────────────────────────────────────────────────────────────────── describe('AuditController', () => { let auditService: jest.Mocked; let controller: AuditController; beforeEach(() => { jest.clearAllMocks(); auditService = new MockAuditService({} as never) as jest.Mocked; controller = new AuditController(auditService); }); // ── queryAuditLog ──────────────────────────────────────────────────────────── describe('queryAuditLog()', () => { it('should return 200 with paginated audit events', async () => { const { req, res, next } = buildMocks(); req.query = { page: '1', limit: '50' }; const paginatedResponse = { data: [MOCK_AUDIT_EVENT], total: 1, page: 1, limit: 50 }; auditService.queryEvents.mockResolvedValue(paginatedResponse); await controller.queryAuditLog(req as Request, res as Response, next); expect(auditService.queryEvents).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(paginatedResponse); }); it('should call next(AuthenticationError) when req.user is missing', async () => { const { req, res, next } = buildMocks(); req.user = undefined; await controller.queryAuditLog(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call auditService.queryEvents regardless of scope (scope enforced by OPA middleware)', async () => { // Scope enforcement has been moved to OpaMiddleware; the controller delegates // to the service for all authenticated requests that reach it. const { req, res, next } = buildMocks('agents:read'); req.query = {}; const emptyResponse = { data: [], total: 0, page: 1, limit: 50 }; auditService.queryEvents.mockResolvedValue(emptyResponse); await controller.queryAuditLog(req as Request, res as Response, next); expect(auditService.queryEvents).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); }); it('should call next(ValidationError) when query params are invalid', async () => { const { req, res, next } = buildMocks(); req.query = { page: 'not-a-number' }; await controller.queryAuditLog(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(ValidationError)); expect(auditService.queryEvents).not.toHaveBeenCalled(); }); it('should pass all optional filters to auditService.queryEvents', async () => { const { req, res, next } = buildMocks(); // agentId must be a valid UUID per auditQuerySchema req.query = { page: '2', limit: '10', agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', action: 'agent.created', outcome: 'success', fromDate: '2026-01-01T00:00:00Z', toDate: '2026-12-31T23:59:59Z', }; const emptyResponse = { data: [], total: 0, page: 2, limit: 10 }; auditService.queryEvents.mockResolvedValue(emptyResponse); await controller.queryAuditLog(req as Request, res as Response, next); expect(auditService.queryEvents).toHaveBeenCalledWith( expect.objectContaining({ page: 2, limit: 10, agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', action: 'agent.created', outcome: 'success', // Joi normalises ISO dates: "2026-01-01T00:00:00Z" → "2026-01-01T00:00:00.000Z" fromDate: expect.stringContaining('2026-01-01'), toDate: expect.stringContaining('2026-12-31'), }), ); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.query = {}; const serviceError = new Error('Service error'); auditService.queryEvents.mockRejectedValue(serviceError); await controller.queryAuditLog(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); // ── getAuditEventById ──────────────────────────────────────────────────────── describe('getAuditEventById()', () => { it('should return 200 with the audit event', async () => { const { req, res, next } = buildMocks(); req.params = { eventId: MOCK_AUDIT_EVENT.eventId }; auditService.getEventById.mockResolvedValue(MOCK_AUDIT_EVENT); await controller.getAuditEventById(req as Request, res as Response, next); expect(auditService.getEventById).toHaveBeenCalledWith(MOCK_AUDIT_EVENT.eventId); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(MOCK_AUDIT_EVENT); }); it('should call next(AuthenticationError) when req.user is missing', async () => { const { req, res, next } = buildMocks(); req.user = undefined; req.params = { eventId: 'any' }; await controller.getAuditEventById(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call auditService.getEventById regardless of scope (scope enforced by OPA middleware)', async () => { // Scope enforcement has been moved to OpaMiddleware; the controller delegates // to the service for all authenticated requests that reach it. const { req, res, next } = buildMocks('agents:read'); req.params = { eventId: MOCK_AUDIT_EVENT.eventId }; auditService.getEventById.mockResolvedValue(MOCK_AUDIT_EVENT); await controller.getAuditEventById(req as Request, res as Response, next); expect(auditService.getEventById).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); }); it('should forward AuditEventNotFoundError to next', async () => { const { req, res, next } = buildMocks(); req.params = { eventId: 'nonexistent' }; const notFound = new AuditEventNotFoundError('nonexistent'); auditService.getEventById.mockRejectedValue(notFound); await controller.getAuditEventById(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(notFound); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.params = { eventId: MOCK_AUDIT_EVENT.eventId }; const serviceError = new Error('DB error'); auditService.getEventById.mockRejectedValue(serviceError); await controller.getAuditEventById(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); });