Files
mcpctl/src/mcpd/tests/audit-log-service.test.ts
Michal 7c07749580 feat: add audit logging repository, service, and query API
Implements IAuditLogRepository with Prisma, AuditLogService with
configurable retention policy and purge, and REST routes for
querying/filtering audit logs at /api/v1/audit-logs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 05:09:14 +00:00

152 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuditLogService } from '../src/services/audit-log.service.js';
import type { IAuditLogRepository } from '../src/repositories/interfaces.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
function makeLog(overrides: Partial<{ id: string; userId: string; action: string; resource: string; resourceId: string | null; createdAt: Date }> = {}) {
return {
id: overrides.id ?? 'log-1',
userId: overrides.userId ?? 'user-1',
action: overrides.action ?? 'CREATE',
resource: overrides.resource ?? 'servers',
resourceId: overrides.resourceId ?? null,
details: {},
createdAt: overrides.createdAt ?? new Date('2025-01-15T00:00:00Z'),
};
}
function mockRepo(): IAuditLogRepository {
return {
findAll: vi.fn(async () => [makeLog()]),
findById: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-log',
userId: data.userId,
action: data.action,
resource: data.resource,
resourceId: data.resourceId ?? null,
details: data.details ?? {},
createdAt: new Date(),
})),
count: vi.fn(async () => 1),
deleteOlderThan: vi.fn(async () => 5),
};
}
describe('AuditLogService', () => {
let repo: ReturnType<typeof mockRepo>;
let service: AuditLogService;
beforeEach(() => {
repo = mockRepo();
service = new AuditLogService(repo, 90);
});
describe('list', () => {
it('returns logs with total count', async () => {
const result = await service.list();
expect(result.logs).toHaveLength(1);
expect(result.total).toBe(1);
expect(repo.findAll).toHaveBeenCalled();
expect(repo.count).toHaveBeenCalled();
});
it('passes filter params to repository', async () => {
await service.list({
userId: 'user-1',
action: 'CREATE',
resource: 'servers',
limit: 50,
offset: 10,
});
expect(repo.findAll).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
action: 'CREATE',
resource: 'servers',
limit: 50,
offset: 10,
}),
);
});
it('parses date strings in since/until', async () => {
await service.list({
since: '2025-01-01T00:00:00Z',
until: '2025-12-31T23:59:59Z',
});
expect(repo.findAll).toHaveBeenCalledWith(
expect.objectContaining({
since: expect.any(Date),
until: expect.any(Date),
}),
);
});
});
describe('getById', () => {
it('returns a log entry', async () => {
vi.mocked(repo.findById).mockResolvedValue(makeLog({ id: 'log-1' }));
const log = await service.getById('log-1');
expect(log.id).toBe('log-1');
});
it('throws NotFoundError when log does not exist', async () => {
await expect(service.getById('nonexistent')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates an audit log entry', async () => {
const log = await service.create({
userId: 'user-1',
action: 'CREATE',
resource: 'servers',
details: { method: 'POST' },
});
expect(log.id).toBe('new-log');
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
action: 'CREATE',
resource: 'servers',
}),
);
});
it('creates log with optional resourceId', async () => {
await service.create({
userId: 'user-1',
action: 'DELETE',
resource: 'servers',
resourceId: 'srv-1',
});
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({ resourceId: 'srv-1' }),
);
});
});
describe('purgeExpired', () => {
it('deletes logs older than retention period', async () => {
const deleted = await service.purgeExpired();
expect(deleted).toBe(5);
expect(repo.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date));
});
it('uses configured retention days', async () => {
const customService = new AuditLogService(repo, 30);
await customService.purgeExpired();
const cutoff = vi.mocked(repo.deleteOlderThan).mock.calls[0]?.[0] as Date;
const now = new Date();
const daysDiff = Math.round((now.getTime() - cutoff.getTime()) / (1000 * 60 * 60 * 24));
expect(daysDiff).toBe(30);
});
});
});