Files
sentryagent-idp/tests/unit/services/CredentialService.test.ts
SentryAgent.ai Developer d3530285b9 feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation:
- Agent Registry Service (CRUD) — full lifecycle management
- OAuth 2.0 Token Service (Client Credentials flow)
- Credential Management (generate, rotate, revoke)
- Immutable Audit Log Service

Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+
Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types
Quality: 18 unit test suites, 244 tests passing, 97%+ coverage
OpenAPI: 4 complete specs (14 endpoints total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:14:41 +00:00

208 lines
8.6 KiB
TypeScript

/**
* 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<typeof CredentialRepository>;
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
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<CredentialRepository>;
let agentRepo: jest.Mocked<AgentRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
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);
});
});
});