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:
194
tests/unit/services/AgentService.test.ts
Normal file
194
tests/unit/services/AgentService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
tests/unit/services/AuditService.test.ts
Normal file
129
tests/unit/services/AuditService.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Unit tests for src/services/AuditService.ts
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AuditService } from '../../../src/services/AuditService';
|
||||
import { AuditRepository } from '../../../src/repositories/AuditRepository';
|
||||
import {
|
||||
AuditEventNotFoundError,
|
||||
RetentionWindowError,
|
||||
ValidationError,
|
||||
} from '../../../src/utils/errors';
|
||||
import { IAuditEvent } from '../../../src/types/index';
|
||||
|
||||
jest.mock('../../../src/repositories/AuditRepository');
|
||||
|
||||
const MockAuditRepo = AuditRepository as jest.MockedClass<typeof AuditRepository>;
|
||||
|
||||
const MOCK_EVENT: IAuditEvent = {
|
||||
eventId: uuidv4(),
|
||||
agentId: uuidv4(),
|
||||
action: 'token.issued',
|
||||
outcome: 'success',
|
||||
ipAddress: '127.0.0.1',
|
||||
userAgent: 'test/1.0',
|
||||
metadata: { scope: 'agents:read' },
|
||||
timestamp: new Date(), // recent timestamp
|
||||
};
|
||||
|
||||
describe('AuditService', () => {
|
||||
let service: AuditService;
|
||||
let auditRepo: jest.Mocked<AuditRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
auditRepo = new MockAuditRepo({} as never) as jest.Mocked<AuditRepository>;
|
||||
service = new AuditService(auditRepo);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// logEvent
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('logEvent()', () => {
|
||||
it('should create an audit event', async () => {
|
||||
auditRepo.create.mockResolvedValue(MOCK_EVENT);
|
||||
const result = await service.logEvent(
|
||||
MOCK_EVENT.agentId,
|
||||
'token.issued',
|
||||
'success',
|
||||
'127.0.0.1',
|
||||
'test/1.0',
|
||||
{ scope: 'agents:read' },
|
||||
);
|
||||
expect(result).toEqual(MOCK_EVENT);
|
||||
expect(auditRepo.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// queryEvents
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('queryEvents()', () => {
|
||||
it('should return paginated events', async () => {
|
||||
auditRepo.findAll.mockResolvedValue({ events: [MOCK_EVENT], total: 1 });
|
||||
const result = await service.queryEvents({ page: 1, limit: 50 });
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw RetentionWindowError for fromDate before 90-day cutoff', async () => {
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 100);
|
||||
await expect(
|
||||
service.queryEvents({ page: 1, limit: 50, fromDate: oldDate.toISOString() }),
|
||||
).rejects.toThrow(RetentionWindowError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError when fromDate is after toDate', async () => {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 5);
|
||||
const past = new Date();
|
||||
past.setDate(past.getDate() - 1);
|
||||
await expect(
|
||||
service.queryEvents({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
fromDate: future.toISOString(),
|
||||
toDate: past.toISOString(),
|
||||
}),
|
||||
).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should not throw for valid date range within retention window', async () => {
|
||||
auditRepo.findAll.mockResolvedValue({ events: [], total: 0 });
|
||||
const recentDate = new Date();
|
||||
recentDate.setDate(recentDate.getDate() - 30);
|
||||
await expect(
|
||||
service.queryEvents({ page: 1, limit: 50, fromDate: recentDate.toISOString() }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getEventById
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('getEventById()', () => {
|
||||
it('should return the event when found within retention window', async () => {
|
||||
auditRepo.findById.mockResolvedValue(MOCK_EVENT);
|
||||
const result = await service.getEventById(MOCK_EVENT.eventId);
|
||||
expect(result).toEqual(MOCK_EVENT);
|
||||
});
|
||||
|
||||
it('should throw AuditEventNotFoundError when not found', async () => {
|
||||
auditRepo.findById.mockResolvedValue(null);
|
||||
await expect(service.getEventById('nonexistent')).rejects.toThrow(AuditEventNotFoundError);
|
||||
});
|
||||
|
||||
it('should throw AuditEventNotFoundError for event outside retention window', async () => {
|
||||
const oldEvent: IAuditEvent = {
|
||||
...MOCK_EVENT,
|
||||
timestamp: new Date('2020-01-01T00:00:00Z'),
|
||||
};
|
||||
auditRepo.findById.mockResolvedValue(oldEvent);
|
||||
await expect(service.getEventById(oldEvent.eventId)).rejects.toThrow(
|
||||
AuditEventNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
207
tests/unit/services/CredentialService.test.ts
Normal file
207
tests/unit/services/CredentialService.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
tests/unit/services/OAuth2Service.test.ts
Normal file
245
tests/unit/services/OAuth2Service.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Unit tests for src/services/OAuth2Service.ts
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OAuth2Service } from '../../../src/services/OAuth2Service';
|
||||
import { TokenRepository } from '../../../src/repositories/TokenRepository';
|
||||
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
|
||||
import { AgentRepository } from '../../../src/repositories/AgentRepository';
|
||||
import { AuditService } from '../../../src/services/AuditService';
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
FreeTierLimitError,
|
||||
InsufficientScopeError,
|
||||
} from '../../../src/utils/errors';
|
||||
import { IAgent, ICredential, ICredentialRow, ITokenPayload } from '../../../src/types/index';
|
||||
import { hashSecret, generateClientSecret } from '../../../src/utils/crypto';
|
||||
|
||||
jest.mock('../../../src/repositories/TokenRepository');
|
||||
jest.mock('../../../src/repositories/CredentialRepository');
|
||||
jest.mock('../../../src/repositories/AgentRepository');
|
||||
jest.mock('../../../src/services/AuditService');
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const MockTokenRepo = TokenRepository as jest.MockedClass<typeof TokenRepository>;
|
||||
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 MOCK_AGENT_ID = uuidv4();
|
||||
const MOCK_AGENT: IAgent = {
|
||||
agentId: MOCK_AGENT_ID,
|
||||
email: 'agent@sentryagent.ai',
|
||||
agentType: 'screener',
|
||||
version: '1.0.0',
|
||||
capabilities: ['agents:read'],
|
||||
owner: 'team-a',
|
||||
deploymentEnv: 'production',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const IP = '127.0.0.1';
|
||||
const UA = 'test/1.0';
|
||||
|
||||
describe('OAuth2Service', () => {
|
||||
let service: OAuth2Service;
|
||||
let tokenRepo: jest.Mocked<TokenRepository>;
|
||||
let credentialRepo: jest.Mocked<CredentialRepository>;
|
||||
let agentRepo: jest.Mocked<AgentRepository>;
|
||||
let auditService: jest.Mocked<AuditService>;
|
||||
|
||||
let plainSecret: string;
|
||||
let credentialRow: ICredentialRow;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
tokenRepo = new MockTokenRepo({} as never, {} as never) as jest.Mocked<TokenRepository>;
|
||||
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 OAuth2Service(
|
||||
tokenRepo,
|
||||
credentialRepo,
|
||||
agentRepo,
|
||||
auditService,
|
||||
privateKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
plainSecret = generateClientSecret();
|
||||
const secretHash = await hashSecret(plainSecret);
|
||||
const credId = uuidv4();
|
||||
|
||||
const mockCredential: ICredential = {
|
||||
credentialId: credId,
|
||||
clientId: MOCK_AGENT_ID,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
credentialRow = { ...mockCredential, secretHash };
|
||||
|
||||
credentialRepo.findByAgentId.mockResolvedValue({ credentials: [mockCredential], total: 1 });
|
||||
credentialRepo.findById.mockResolvedValue(credentialRow);
|
||||
auditService.logEvent.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// issueToken
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('issueToken()', () => {
|
||||
beforeEach(() => {
|
||||
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
||||
tokenRepo.getMonthlyCount.mockResolvedValue(0);
|
||||
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('should issue a token for valid credentials', async () => {
|
||||
const result = await service.issueToken(
|
||||
MOCK_AGENT_ID,
|
||||
plainSecret,
|
||||
'agents:read',
|
||||
IP,
|
||||
UA,
|
||||
);
|
||||
expect(result.token_type).toBe('Bearer');
|
||||
expect(result.expires_in).toBe(3600);
|
||||
expect(result.access_token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError for unknown agent', async () => {
|
||||
agentRepo.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.issueToken('unknown', plainSecret, 'agents:read', IP, UA),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('should throw AuthenticationError for wrong secret', async () => {
|
||||
await expect(
|
||||
service.issueToken(MOCK_AGENT_ID, 'wrong_secret', 'agents:read', IP, UA),
|
||||
).rejects.toThrow(AuthenticationError);
|
||||
});
|
||||
|
||||
it('should throw AuthorizationError for suspended agent', async () => {
|
||||
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
|
||||
await expect(
|
||||
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
it('should throw AuthorizationError for decommissioned agent', async () => {
|
||||
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
|
||||
await expect(
|
||||
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
it('should throw FreeTierLimitError when monthly limit reached', async () => {
|
||||
tokenRepo.getMonthlyCount.mockResolvedValue(10000);
|
||||
await expect(
|
||||
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
|
||||
).rejects.toThrow(FreeTierLimitError);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// introspectToken
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('introspectToken()', () => {
|
||||
let validToken: string;
|
||||
let callerPayload: ITokenPayload;
|
||||
|
||||
beforeEach(async () => {
|
||||
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
||||
tokenRepo.getMonthlyCount.mockResolvedValue(0);
|
||||
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
|
||||
|
||||
const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read tokens:read', IP, UA);
|
||||
validToken = issued.access_token;
|
||||
|
||||
const { verifyToken } = await import('../../../src/utils/jwt');
|
||||
callerPayload = verifyToken(validToken, publicKey);
|
||||
});
|
||||
|
||||
it('should return active: true for a valid token', async () => {
|
||||
tokenRepo.isRevoked.mockResolvedValue(false);
|
||||
const result = await service.introspectToken(validToken, callerPayload, IP, UA);
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.sub).toBe(MOCK_AGENT_ID);
|
||||
});
|
||||
|
||||
it('should return active: false for a revoked token', async () => {
|
||||
tokenRepo.isRevoked.mockResolvedValue(true);
|
||||
const result = await service.introspectToken(validToken, callerPayload, IP, UA);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw InsufficientScopeError if caller lacks tokens:read', async () => {
|
||||
const noScopePayload = { ...callerPayload, scope: 'agents:read' };
|
||||
await expect(
|
||||
service.introspectToken(validToken, noScopePayload, IP, UA),
|
||||
).rejects.toThrow(InsufficientScopeError);
|
||||
});
|
||||
|
||||
it('should return active: false for an expired token', async () => {
|
||||
const result = await service.introspectToken('invalid.jwt.token', callerPayload, IP, UA);
|
||||
expect(result.active).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// revokeToken
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
describe('revokeToken()', () => {
|
||||
let validToken: string;
|
||||
let callerPayload: ITokenPayload;
|
||||
|
||||
beforeEach(async () => {
|
||||
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
|
||||
tokenRepo.getMonthlyCount.mockResolvedValue(0);
|
||||
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
|
||||
|
||||
const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA);
|
||||
validToken = issued.access_token;
|
||||
|
||||
const { verifyToken } = await import('../../../src/utils/jwt');
|
||||
callerPayload = verifyToken(validToken, publicKey);
|
||||
|
||||
tokenRepo.addToRevocationList.mockResolvedValue();
|
||||
});
|
||||
|
||||
it('should revoke a token successfully', async () => {
|
||||
await expect(
|
||||
service.revokeToken(validToken, callerPayload, IP, UA),
|
||||
).resolves.toBeUndefined();
|
||||
expect(tokenRepo.addToRevocationList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw AuthorizationError if revoking another agent token', async () => {
|
||||
const otherPayload = { ...callerPayload, sub: uuidv4() };
|
||||
await expect(
|
||||
service.revokeToken(validToken, otherPayload, IP, UA),
|
||||
).rejects.toThrow(AuthorizationError);
|
||||
});
|
||||
|
||||
it('should succeed silently for a malformed token (RFC 7009)', async () => {
|
||||
await expect(
|
||||
service.revokeToken('not.a.valid.token', callerPayload, IP, UA),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user