/** * 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 { VaultClient } from '../../../src/vault/VaultClient'; 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'); jest.mock('../../../src/vault/VaultClient'); const MockCredentialRepo = CredentialRepository as jest.MockedClass; const MockAgentRepo = AgentRepository as jest.MockedClass; const MockAuditService = AuditService as jest.MockedClass; const MockVaultClient = VaultClient 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', vaultPath: null, }; 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); }); }); }); // ─── Vault-path tests ────────────────────────────────────────────────────── describe('CredentialService — Vault path (Phase 2)', () => { let service: CredentialService; let credentialRepo: jest.Mocked; let agentRepo: jest.Mocked; let auditService: jest.Mocked; let vaultClient: jest.Mocked; const VAULT_PATH = `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CREDENTIAL_ID}`; const MOCK_VAULT_CREDENTIAL_ROW: ICredentialRow = { ...MOCK_CREDENTIAL, secretHash: '', vaultPath: VAULT_PATH, }; 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; vaultClient = new MockVaultClient('http://localhost:8200', 'token') as jest.Mocked; service = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient); auditService.logEvent.mockResolvedValue({} as never); }); describe('generateCredential() with Vault', () => { it('writes secret to Vault and stores the vault_path in the DB', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); vaultClient.writeSecret.mockResolvedValue(VAULT_PATH); credentialRepo.createWithVaultPath.mockResolvedValue(MOCK_CREDENTIAL); const result = await service.generateCredential(AGENT_ID, {}, IP, UA); expect(vaultClient.writeSecret).toHaveBeenCalledWith( AGENT_ID, expect.any(String), expect.any(String), ); expect(credentialRepo.createWithVaultPath).toHaveBeenCalled(); expect(credentialRepo.create).not.toHaveBeenCalled(); expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/); }); }); describe('rotateCredential() with Vault', () => { it('writes new Vault version and updates vault_path in the DB', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW); vaultClient.writeSecret.mockResolvedValue(VAULT_PATH); credentialRepo.updateVaultPath.mockResolvedValue(MOCK_CREDENTIAL); const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA); expect(vaultClient.writeSecret).toHaveBeenCalledWith( AGENT_ID, CREDENTIAL_ID, expect.any(String), ); expect(credentialRepo.updateVaultPath).toHaveBeenCalled(); expect(credentialRepo.updateHash).not.toHaveBeenCalled(); expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/); }); }); describe('revokeCredential() with Vault', () => { it('revokes DB record and deletes Vault secret', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW); credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() }); vaultClient.deleteSecret.mockResolvedValue(); await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA); expect(credentialRepo.revoke).toHaveBeenCalledWith(CREDENTIAL_ID); expect(vaultClient.deleteSecret).toHaveBeenCalledWith(AGENT_ID, CREDENTIAL_ID); }); it('does not call Vault delete when credential has no vault_path (bcrypt credential)', async () => { agentRepo.findById.mockResolvedValue(MOCK_AGENT); credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); // vaultPath: null credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() }); await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA); expect(vaultClient.deleteSecret).not.toHaveBeenCalled(); }); }); });