Introduces full multi-tenant organization model to AgentIdP: Schema: - 6 migrations: organizations + organization_members tables; organization_id FK added to agents, credentials, audit_logs; PostgreSQL RLS policies on all three tables; system org seed + backfill API: - 6 new /api/v1/organizations endpoints (CRUD + members) gated by admin:orgs scope - OPA scopes.json updated with 6 new org endpoint → admin:orgs mappings Implementation: - OrgRepository, OrgService, OrgController, createOrgsRouter - OrgContextMiddleware: sets app.organization_id session variable so RLS enforces per-request org isolation at the database layer - JWT payload extended with organization_id claim; auth.ts backfills org_system for backward-compatible tokens - New error classes: OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError Tests: 373 passing, 80.64% branch coverage, zero any types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
|
* 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',
|
|
organizationId: 'org_system',
|
|
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);
|
|
});
|
|
|
|
it('should throw AgentNotFoundError when update() returns null (race condition)', async () => {
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
agentRepo.update.mockResolvedValue(null);
|
|
await expect(
|
|
agentService.updateAgent(MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA),
|
|
).rejects.toThrow(AgentNotFoundError);
|
|
});
|
|
|
|
it('should log agent.suspended audit action when status changes to suspended', async () => {
|
|
const updated = { ...MOCK_AGENT, status: 'suspended' as const };
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
agentRepo.update.mockResolvedValue(updated);
|
|
auditService.logEvent.mockResolvedValue({} as never);
|
|
|
|
await agentService.updateAgent(MOCK_AGENT.agentId, { status: 'suspended' }, IP, UA);
|
|
|
|
expect(auditService.logEvent).toHaveBeenCalledWith(
|
|
MOCK_AGENT.agentId,
|
|
'agent.suspended',
|
|
'success',
|
|
IP,
|
|
UA,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should log agent.reactivated audit action when status changes from suspended to active', async () => {
|
|
const suspended = { ...MOCK_AGENT, status: 'suspended' as const };
|
|
const reactivated = { ...MOCK_AGENT, status: 'active' as const };
|
|
agentRepo.findById.mockResolvedValue(suspended);
|
|
agentRepo.update.mockResolvedValue(reactivated);
|
|
auditService.logEvent.mockResolvedValue({} as never);
|
|
|
|
await agentService.updateAgent(suspended.agentId, { status: 'active' }, IP, UA);
|
|
|
|
expect(auditService.logEvent).toHaveBeenCalledWith(
|
|
suspended.agentId,
|
|
'agent.reactivated',
|
|
'success',
|
|
IP,
|
|
UA,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should log agent.decommissioned audit action when status changes to decommissioned via update', async () => {
|
|
const updated = { ...MOCK_AGENT, status: 'decommissioned' as const };
|
|
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
|
agentRepo.update.mockResolvedValue(updated);
|
|
auditService.logEvent.mockResolvedValue({} as never);
|
|
|
|
await agentService.updateAgent(MOCK_AGENT.agentId, { status: 'decommissioned' }, IP, UA);
|
|
|
|
expect(auditService.logEvent).toHaveBeenCalledWith(
|
|
MOCK_AGENT.agentId,
|
|
'agent.decommissioned',
|
|
'success',
|
|
IP,
|
|
UA,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ────────────────────────────────────────────────────────────────
|
|
// 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);
|
|
});
|
|
});
|
|
});
|