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>
130 lines
5.1 KiB
TypeScript
130 lines
5.1 KiB
TypeScript
/**
|
|
* 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,
|
|
);
|
|
});
|
|
});
|
|
});
|