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>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View File

@@ -0,0 +1,194 @@
/**
* Unit tests for src/services/AgentService.ts
*/
import { AgentService } from '../../../src/services/AgentService';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AuditService } from '../../../src/services/AuditService';
import {
AgentNotFoundError,
AgentAlreadyExistsError,
AgentAlreadyDecommissionedError,
FreeTierLimitError,
} from '../../../src/utils/errors';
import { IAgent, ICreateAgentRequest } from '../../../src/types/index';
// Mock dependencies
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/services/AuditService');
const MockAgentRepository = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockCredentialRepository = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const MOCK_AGENT: IAgent = {
agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date('2026-03-28T09:00:00Z'),
updatedAt: new Date('2026-03-28T09:00:00Z'),
};
const IP = '127.0.0.1';
const UA = 'test-agent/1.0';
describe('AgentService', () => {
let agentService: AgentService;
let agentRepo: jest.Mocked<AgentRepository>;
let credentialRepo: jest.Mocked<CredentialRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
agentRepo = new MockAgentRepository({} as never) as jest.Mocked<AgentRepository>;
credentialRepo = new MockCredentialRepository({} as never) as jest.Mocked<CredentialRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
agentService = new AgentService(agentRepo, credentialRepo, auditService);
});
// ────────────────────────────────────────────────────────────────
// registerAgent
// ────────────────────────────────────────────────────────────────
describe('registerAgent()', () => {
const createData: ICreateAgentRequest = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
it('should create and return a new agent', async () => {
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(null);
agentRepo.create.mockResolvedValue(MOCK_AGENT);
auditService.logEvent.mockResolvedValue({} as never);
const result = await agentService.registerAgent(createData, IP, UA);
expect(result).toEqual(MOCK_AGENT);
expect(agentRepo.create).toHaveBeenCalledWith(createData);
});
it('should throw FreeTierLimitError when 100 agents already registered', async () => {
agentRepo.countActive.mockResolvedValue(100);
await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow(
FreeTierLimitError,
);
});
it('should throw AgentAlreadyExistsError if email is already registered', async () => {
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(MOCK_AGENT);
await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow(
AgentAlreadyExistsError,
);
});
});
// ────────────────────────────────────────────────────────────────
// getAgentById
// ────────────────────────────────────────────────────────────────
describe('getAgentById()', () => {
it('should return the agent when found', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
const result = await agentService.getAgentById(MOCK_AGENT.agentId);
expect(result).toEqual(MOCK_AGENT);
});
it('should throw AgentNotFoundError when not found', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(agentService.getAgentById('nonexistent-id')).rejects.toThrow(
AgentNotFoundError,
);
});
});
// ────────────────────────────────────────────────────────────────
// listAgents
// ────────────────────────────────────────────────────────────────
describe('listAgents()', () => {
it('should return a paginated list of agents', async () => {
agentRepo.findAll.mockResolvedValue({ agents: [MOCK_AGENT], total: 1 });
const result = await agentService.listAgents({ page: 1, limit: 20 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
});
});
// ────────────────────────────────────────────────────────────────
// updateAgent
// ────────────────────────────────────────────────────────────────
describe('updateAgent()', () => {
it('should update and return the agent', async () => {
const updated = { ...MOCK_AGENT, version: '2.0.0' };
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
agentRepo.update.mockResolvedValue(updated);
auditService.logEvent.mockResolvedValue({} as never);
const result = await agentService.updateAgent(
MOCK_AGENT.agentId,
{ version: '2.0.0' },
IP,
UA,
);
expect(result.version).toBe('2.0.0');
});
it('should throw AgentNotFoundError when agent does not exist', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
agentService.updateAgent('nonexistent', { version: '2.0.0' }, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw AgentAlreadyDecommissionedError for decommissioned agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(
agentService.updateAgent(MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA),
).rejects.toThrow(AgentAlreadyDecommissionedError);
});
});
// ────────────────────────────────────────────────────────────────
// decommissionAgent
// ────────────────────────────────────────────────────────────────
describe('decommissionAgent()', () => {
it('should decommission the agent and revoke credentials', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.revokeAllForAgent.mockResolvedValue(2);
agentRepo.decommission.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
auditService.logEvent.mockResolvedValue({} as never);
await agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA);
expect(credentialRepo.revokeAllForAgent).toHaveBeenCalledWith(MOCK_AGENT.agentId);
expect(agentRepo.decommission).toHaveBeenCalledWith(MOCK_AGENT.agentId);
});
it('should throw AgentNotFoundError if agent does not exist', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
agentService.decommissionAgent('nonexistent', IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw AgentAlreadyDecommissionedError if already decommissioned', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(
agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA),
).rejects.toThrow(AgentAlreadyDecommissionedError);
});
});
});