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>
This commit is contained in:
111
src/mcpd/tests/audit-log-routes.test.ts
Normal file
111
src/mcpd/tests/audit-log-routes.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { registerAuditLogRoutes } from '../src/routes/audit-logs.js';
|
||||
import { AuditLogService } from '../src/services/audit-log.service.js';
|
||||
import type { IAuditLogRepository } from '../src/repositories/interfaces.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
|
||||
function makeLog(id: string) {
|
||||
return {
|
||||
id,
|
||||
userId: 'user-1',
|
||||
action: 'CREATE',
|
||||
resource: 'servers',
|
||||
resourceId: null,
|
||||
details: {},
|
||||
createdAt: new Date('2025-01-15T00:00:00Z'),
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(): IAuditLogRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [makeLog('log-1'), makeLog('log-2')]),
|
||||
findById: vi.fn(async () => null),
|
||||
create: vi.fn(async () => makeLog('new-log')),
|
||||
count: vi.fn(async () => 2),
|
||||
deleteOlderThan: vi.fn(async () => 3),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Audit Log Routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let repo: ReturnType<typeof mockRepo>;
|
||||
let service: AuditLogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify();
|
||||
app.setErrorHandler(errorHandler);
|
||||
repo = mockRepo();
|
||||
service = new AuditLogService(repo, 90);
|
||||
registerAuditLogRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('GET /api/v1/audit-logs', () => {
|
||||
it('returns paginated audit logs', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.logs).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it('passes query filters to service', async () => {
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/audit-logs?userId=user-1&action=CREATE&resource=servers&limit=10&offset=5',
|
||||
});
|
||||
|
||||
expect(repo.findAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
action: 'CREATE',
|
||||
resource: 'servers',
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes date range filters', async () => {
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/audit-logs?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('GET /api/v1/audit-logs/:id', () => {
|
||||
it('returns a specific log entry', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeLog('log-1'));
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs/log-1' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().id).toBe('log-1');
|
||||
});
|
||||
|
||||
it('returns 404 for missing log', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs/nonexistent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/audit-logs/purge', () => {
|
||||
it('purges expired logs and returns count', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/v1/audit-logs/purge' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().deleted).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
src/mcpd/tests/audit-log-service.test.ts
Normal file
151
src/mcpd/tests/audit-log-service.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user