/** * Unit tests for src/controllers/CredentialController.ts * CredentialService is mocked; handlers are invoked with mock req/res/next. */ import { Request, Response, NextFunction } from 'express'; import { CredentialController } from '../../../src/controllers/CredentialController'; import { CredentialService } from '../../../src/services/CredentialService'; import { ITokenPayload, ICredential, ICredentialWithSecret } from '../../../src/types/index'; import { ValidationError, AuthenticationError, AuthorizationError, CredentialNotFoundError, } from '../../../src/utils/errors'; jest.mock('../../../src/services/CredentialService'); const MockCredentialService = CredentialService as jest.MockedClass; // ─── helpers ───────────────────────────────────────────────────────────────── const AGENT_ID = 'agent-id-001'; const MOCK_USER: ITokenPayload = { sub: AGENT_ID, client_id: AGENT_ID, scope: 'agents:write', jti: 'jti-001', iat: 1000, exp: 9999999999, }; const MOCK_CREDENTIAL: ICredential = { credentialId: 'cred-id-001', clientId: AGENT_ID, status: 'active', createdAt: new Date('2026-03-28T09:00:00Z'), expiresAt: null, revokedAt: null, }; const MOCK_CREDENTIAL_WITH_SECRET: ICredentialWithSecret = { ...MOCK_CREDENTIAL, clientSecret: 'sa_plain_text_secret_here', }; function buildMocks(overrideUser?: ITokenPayload | undefined): { req: Partial; res: Partial; next: NextFunction; } { const res: Partial = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), }; return { req: { user: overrideUser !== undefined ? overrideUser : MOCK_USER, body: {}, params: { agentId: AGENT_ID }, query: {}, headers: {}, ip: '127.0.0.1', }, res, next: jest.fn() as NextFunction, }; } // ─── suite ─────────────────────────────────────────────────────────────────── describe('CredentialController', () => { let credentialService: jest.Mocked; let controller: CredentialController; beforeEach(() => { jest.clearAllMocks(); credentialService = new MockCredentialService( {} as never, {} as never, {} as never, ) as jest.Mocked; controller = new CredentialController(credentialService); }); // ── generateCredential ─────────────────────────────────────────────────────── describe('generateCredential()', () => { it('should return 201 with credential-with-secret on success', async () => { const { req, res, next } = buildMocks(); req.body = {}; credentialService.generateCredential.mockResolvedValue(MOCK_CREDENTIAL_WITH_SECRET); await controller.generateCredential(req as Request, res as Response, next); expect(credentialService.generateCredential).toHaveBeenCalledWith( AGENT_ID, expect.any(Object), '127.0.0.1', expect.any(String), ); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith(MOCK_CREDENTIAL_WITH_SECRET); }); it('should call next(AuthenticationError) when req.user is missing', async () => { const { req, res, next } = buildMocks(undefined); req.user = undefined; await controller.generateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call next(AuthorizationError) when user.sub does not match agentId', async () => { const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' }); req.params = { agentId: AGENT_ID }; await controller.generateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should call next(ValidationError) when expiresAt is in the past', async () => { const { req, res, next } = buildMocks(); req.body = { expiresAt: '2020-01-01T00:00:00Z' }; // past date await controller.generateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(ValidationError)); expect(credentialService.generateCredential).not.toHaveBeenCalled(); }); it('should call next(ValidationError) when body schema is invalid', async () => { const { req, res, next } = buildMocks(); req.body = { expiresAt: 'not-a-date' }; await controller.generateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(ValidationError)); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.body = {}; const serviceError = new Error('Service error'); credentialService.generateCredential.mockRejectedValue(serviceError); await controller.generateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); // ── listCredentials ─────────────────────────────────────────────────────────── describe('listCredentials()', () => { it('should return 200 with paginated credentials', async () => { const { req, res, next } = buildMocks(); req.query = { page: '1', limit: '20' }; const paginatedResponse = { data: [MOCK_CREDENTIAL], total: 1, page: 1, limit: 20 }; credentialService.listCredentials.mockResolvedValue(paginatedResponse); await controller.listCredentials(req as Request, res as Response, next); 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(undefined); req.user = undefined; await controller.listCredentials(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call next(AuthorizationError) when user.sub does not match agentId', async () => { const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' }); await controller.listCredentials(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should call next(ValidationError) when query params are invalid', async () => { const { req, res, next } = buildMocks(); req.query = { page: 'bad' }; await controller.listCredentials(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(ValidationError)); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.query = {}; const serviceError = new Error('Service error'); credentialService.listCredentials.mockRejectedValue(serviceError); await controller.listCredentials(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); // ── rotateCredential ────────────────────────────────────────────────────────── describe('rotateCredential()', () => { it('should return 200 with new credential-with-secret on success', async () => { const { req, res, next } = buildMocks(); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; req.body = {}; credentialService.rotateCredential.mockResolvedValue(MOCK_CREDENTIAL_WITH_SECRET); await controller.rotateCredential(req as Request, res as Response, next); expect(credentialService.rotateCredential).toHaveBeenCalledWith( AGENT_ID, 'cred-id-001', expect.any(Object), '127.0.0.1', expect.any(String), ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(MOCK_CREDENTIAL_WITH_SECRET); }); it('should call next(AuthenticationError) when req.user is missing', async () => { const { req, res, next } = buildMocks(undefined); req.user = undefined; req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; await controller.rotateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call next(AuthorizationError) when user.sub does not match agentId', async () => { const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' }); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; await controller.rotateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should call next(ValidationError) when expiresAt is in the past', async () => { const { req, res, next } = buildMocks(); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; req.body = { expiresAt: '2020-01-01T00:00:00Z' }; await controller.rotateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(ValidationError)); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; req.body = {}; const serviceError = new CredentialNotFoundError('cred-id-001'); credentialService.rotateCredential.mockRejectedValue(serviceError); await controller.rotateCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); // ── revokeCredential ────────────────────────────────────────────────────────── describe('revokeCredential()', () => { it('should return 204 on success', async () => { const { req, res, next } = buildMocks(); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; credentialService.revokeCredential.mockResolvedValue(); await controller.revokeCredential(req as Request, res as Response, next); expect(credentialService.revokeCredential).toHaveBeenCalledWith( AGENT_ID, 'cred-id-001', '127.0.0.1', expect.any(String), ); expect(res.status).toHaveBeenCalledWith(204); expect(res.send).toHaveBeenCalled(); expect(next).not.toHaveBeenCalled(); }); it('should call next(AuthenticationError) when req.user is missing', async () => { const { req, res, next } = buildMocks(undefined); req.user = undefined; req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; await controller.revokeCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError)); }); it('should call next(AuthorizationError) when user.sub does not match agentId', async () => { const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' }); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; await controller.revokeCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should forward service errors to next', async () => { const { req, res, next } = buildMocks(); req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' }; const serviceError = new CredentialNotFoundError('cred-id-001'); credentialService.revokeCredential.mockRejectedValue(serviceError); await controller.revokeCredential(req as Request, res as Response, next); expect(next).toHaveBeenCalledWith(serviceError); }); }); });