/** * Unit tests for src/services/CredentialService.ts */ import { v4 as uuidv4 } from 'uuid'; import { CredentialService } from '../../../src/services/CredentialService'; import { CredentialRepository } from '../../../src/repositories/CredentialRepository'; import { AgentRepository } from '../../../src/repositories/AgentRepository'; import { AuditService } from '../../../src/services/AuditService'; import { AgentNotFoundError, CredentialNotFoundError, CredentialAlreadyRevokedError, CredentialError, } from '../../../src/utils/errors'; import { IAgent, ICredential, ICredentialRow } from '../../../src/types/index'; jest.mock('../../../src/repositories/CredentialRepository'); jest.mock('../../../src/repositories/AgentRepository'); jest.mock('../../../src/services/AuditService'); const MockCredentialRepo = CredentialRepository as jest.MockedClass; const MockAgentRepo = AgentRepository as jest.MockedClass; const MockAuditService = AuditService as jest.MockedClass; const AGENT_ID = uuidv4(); const CREDENTIAL_ID = uuidv4(); const MOCK_AGENT: IAgent = { agentId: AGENT_ID, email: 'agent@sentryagent.ai', agentType: 'screener', version: '1.0.0', capabilities: ['resume:read'], owner: 'team-a', deploymentEnv: 'production', status: 'active', createdAt: new Date(), updatedAt: new Date(), }; const MOCK_CREDENTIAL: ICredential = { credentialId: CREDENTIAL_ID, clientId: AGENT_ID, status: 'active', createdAt: new Date(), expiresAt: null, revokedAt: null, }; const MOCK_CREDENTIAL_ROW: ICredentialRow = { ...MOCK_CREDENTIAL, secretHash: '$2b$10$somehashvalue', }; const IP = '127.0.0.1'; const UA = 'test/1.0'; describe('CredentialService', () => { let service: CredentialService; let credentialRepo: jest.Mocked; let agentRepo: jest.Mocked; let auditService: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked; agentRepo = new MockAgentRepo({} as never) as jest.Mocked; auditService = new MockAuditService({} as never) as jest.Mocked; service = new CredentialService(credentialRepo, agentRepo, auditService); auditService.logEvent.mockResolvedValue({} as never); }); // ──────────────────────────────────────────────────────────────── // generateCredential // ──────────────────────────────────────────────────────────────── describe('generateCredential()', () => { it('should generate and return a credential with a one-time secret', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.create.mockResolvedValue(MOCK_CREDENTIAL); const result = await service.generateCredential(AGENT_ID, {}, IP, UA); expect(result.credentialId).toBe(CREDENTIAL_ID); expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/); }); it('should throw AgentNotFoundError for unknown agent', async () => { agentRepo.findById.mockResolvedValue(null); await expect(service.generateCredential('unknown', {}, IP, UA)).rejects.toThrow( AgentNotFoundError, ); }); it('should throw CredentialError for suspended agent', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' }); await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow( CredentialError, ); }); it('should throw CredentialError for decommissioned agent', async () => { agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' }); await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow( CredentialError, ); }); }); // ──────────────────────────────────────────────────────────────── // listCredentials // ──────────────────────────────────────────────────────────────── describe('listCredentials()', () => { it('should return a paginated list', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findByAgentId.mockResolvedValue({ credentials: [MOCK_CREDENTIAL], total: 1, }); const result = await service.listCredentials(AGENT_ID, { page: 1, limit: 20 }); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); }); it('should throw AgentNotFoundError for unknown agent', async () => { agentRepo.findById.mockResolvedValue(null); await expect( service.listCredentials('unknown', { page: 1, limit: 20 }), ).rejects.toThrow(AgentNotFoundError); }); }); // ──────────────────────────────────────────────────────────────── // rotateCredential // ──────────────────────────────────────────────────────────────── describe('rotateCredential()', () => { it('should rotate and return a new secret', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); credentialRepo.updateHash.mockResolvedValue(MOCK_CREDENTIAL); const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA); expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/); }); it('should throw AgentNotFoundError for unknown agent', async () => { agentRepo.findById.mockResolvedValue(null); await expect( service.rotateCredential('unknown', CREDENTIAL_ID, {}, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); it('should throw CredentialNotFoundError for unknown credential', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(null); await expect( service.rotateCredential(AGENT_ID, 'unknown', {}, IP, UA), ).rejects.toThrow(CredentialNotFoundError); }); it('should throw CredentialAlreadyRevokedError for revoked credential', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue({ ...MOCK_CREDENTIAL_ROW, status: 'revoked', revokedAt: new Date(), }); await expect( service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA), ).rejects.toThrow(CredentialAlreadyRevokedError); }); }); // ──────────────────────────────────────────────────────────────── // revokeCredential // ──────────────────────────────────────────────────────────────── describe('revokeCredential()', () => { it('should revoke the credential', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() }); await expect( service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA), ).resolves.toBeUndefined(); }); it('should throw AgentNotFoundError for unknown agent', async () => { agentRepo.findById.mockResolvedValue(null); await expect( service.revokeCredential('unknown', CREDENTIAL_ID, IP, UA), ).rejects.toThrow(AgentNotFoundError); }); it('should throw CredentialAlreadyRevokedError for already-revoked credential', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue({ ...MOCK_CREDENTIAL_ROW, status: 'revoked', revokedAt: new Date(), }); await expect( service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA), ).rejects.toThrow(CredentialAlreadyRevokedError); }); }); });