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,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,
);
});
});
});