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:
70
src/mcpd/src/repositories/audit-log.repository.ts
Normal file
70
src/mcpd/src/repositories/audit-log.repository.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PrismaClient, AuditLog, Prisma } from '@prisma/client';
|
||||||
|
import type { IAuditLogRepository, AuditLogFilter } from './interfaces.js';
|
||||||
|
|
||||||
|
export class AuditLogRepository implements IAuditLogRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(filter?: AuditLogFilter): Promise<AuditLog[]> {
|
||||||
|
const where = buildWhere(filter);
|
||||||
|
return this.prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: filter?.limit ?? 100,
|
||||||
|
skip: filter?.offset ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<AuditLog | null> {
|
||||||
|
return this.prisma.auditLog.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: {
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
resource: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}): Promise<AuditLog> {
|
||||||
|
const createData: Prisma.AuditLogUncheckedCreateInput = {
|
||||||
|
userId: data.userId,
|
||||||
|
action: data.action,
|
||||||
|
resource: data.resource,
|
||||||
|
details: (data.details ?? {}) as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
if (data.resourceId !== undefined) {
|
||||||
|
createData.resourceId = data.resourceId;
|
||||||
|
}
|
||||||
|
return this.prisma.auditLog.create({ data: createData });
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filter?: AuditLogFilter): Promise<number> {
|
||||||
|
const where = buildWhere(filter);
|
||||||
|
return this.prisma.auditLog.count({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOlderThan(date: Date): Promise<number> {
|
||||||
|
const result = await this.prisma.auditLog.deleteMany({
|
||||||
|
where: { createdAt: { lt: date } },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWhere(filter?: AuditLogFilter): Prisma.AuditLogWhereInput {
|
||||||
|
const where: Prisma.AuditLogWhereInput = {};
|
||||||
|
if (!filter) return where;
|
||||||
|
|
||||||
|
if (filter.userId !== undefined) where.userId = filter.userId;
|
||||||
|
if (filter.action !== undefined) where.action = filter.action;
|
||||||
|
if (filter.resource !== undefined) where.resource = filter.resource;
|
||||||
|
if (filter.resourceId !== undefined) where.resourceId = filter.resourceId;
|
||||||
|
|
||||||
|
if (filter.since !== undefined || filter.until !== undefined) {
|
||||||
|
const createdAt: Prisma.DateTimeFilter = {};
|
||||||
|
if (filter.since !== undefined) createdAt.gte = filter.since;
|
||||||
|
if (filter.until !== undefined) createdAt.lte = filter.until;
|
||||||
|
where.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository } from './interfaces.js';
|
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
|
||||||
export { McpServerRepository } from './mcp-server.repository.js';
|
export { McpServerRepository } from './mcp-server.repository.js';
|
||||||
export { McpProfileRepository } from './mcp-profile.repository.js';
|
export { McpProfileRepository } from './mcp-profile.repository.js';
|
||||||
export type { IProjectRepository } from './project.repository.js';
|
export type { IProjectRepository } from './project.repository.js';
|
||||||
export { ProjectRepository } from './project.repository.js';
|
export { ProjectRepository } from './project.repository.js';
|
||||||
export { McpInstanceRepository } from './mcp-instance.repository.js';
|
export { McpInstanceRepository } from './mcp-instance.repository.js';
|
||||||
|
export { AuditLogRepository } from './audit-log.repository.js';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { McpServer, McpProfile, McpInstance, InstanceStatus } from '@prisma/client';
|
import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client';
|
||||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||||
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
|
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
|
||||||
|
|
||||||
@@ -28,3 +28,22 @@ export interface IMcpProfileRepository {
|
|||||||
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
|
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilter {
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
resource?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
since?: Date;
|
||||||
|
until?: Date;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAuditLogRepository {
|
||||||
|
findAll(filter?: AuditLogFilter): Promise<AuditLog[]>;
|
||||||
|
findById(id: string): Promise<AuditLog | null>;
|
||||||
|
create(data: { userId: string; action: string; resource: string; resourceId?: string; details?: Record<string, unknown> }): Promise<AuditLog>;
|
||||||
|
count(filter?: AuditLogFilter): Promise<number>;
|
||||||
|
deleteOlderThan(date: Date): Promise<number>;
|
||||||
|
}
|
||||||
|
|||||||
39
src/mcpd/src/routes/audit-logs.ts
Normal file
39
src/mcpd/src/routes/audit-logs.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AuditLogService } from '../services/audit-log.service.js';
|
||||||
|
|
||||||
|
interface AuditLogQuery {
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
resource?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
limit?: string;
|
||||||
|
offset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuditLogRoutes(app: FastifyInstance, service: AuditLogService): void {
|
||||||
|
app.get<{ Querystring: AuditLogQuery }>('/api/v1/audit-logs', async (request) => {
|
||||||
|
const q = request.query;
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (q.userId !== undefined) params.userId = q.userId;
|
||||||
|
if (q.action !== undefined) params.action = q.action;
|
||||||
|
if (q.resource !== undefined) params.resource = q.resource;
|
||||||
|
if (q.resourceId !== undefined) params.resourceId = q.resourceId;
|
||||||
|
if (q.since !== undefined) params.since = q.since;
|
||||||
|
if (q.until !== undefined) params.until = q.until;
|
||||||
|
if (q.limit !== undefined) params.limit = parseInt(q.limit, 10);
|
||||||
|
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
|
||||||
|
return service.list(params);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/v1/audit-logs/:id', async (request) => {
|
||||||
|
return service.getById(request.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/audit-logs/purge', async (_request, reply) => {
|
||||||
|
const deleted = await service.purgeExpired();
|
||||||
|
reply.code(200);
|
||||||
|
return { deleted };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { registerMcpServerRoutes } from './mcp-servers.js';
|
|||||||
export { registerMcpProfileRoutes } from './mcp-profiles.js';
|
export { registerMcpProfileRoutes } from './mcp-profiles.js';
|
||||||
export { registerProjectRoutes } from './projects.js';
|
export { registerProjectRoutes } from './projects.js';
|
||||||
export { registerInstanceRoutes } from './instances.js';
|
export { registerInstanceRoutes } from './instances.js';
|
||||||
|
export { registerAuditLogRoutes } from './audit-logs.js';
|
||||||
|
|||||||
73
src/mcpd/src/services/audit-log.service.ts
Normal file
73
src/mcpd/src/services/audit-log.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { AuditLog } from '@prisma/client';
|
||||||
|
import type { IAuditLogRepository, AuditLogFilter } from '../repositories/interfaces.js';
|
||||||
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
|
|
||||||
|
export interface AuditLogQueryParams {
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
resource?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default retention: 90 days */
|
||||||
|
const DEFAULT_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
|
export class AuditLogService {
|
||||||
|
constructor(
|
||||||
|
private readonly repo: IAuditLogRepository,
|
||||||
|
private readonly retentionDays: number = DEFAULT_RETENTION_DAYS,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(params?: AuditLogQueryParams): Promise<{ logs: AuditLog[]; total: number }> {
|
||||||
|
const filter = this.buildFilter(params);
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
this.repo.findAll(filter),
|
||||||
|
this.repo.count(filter),
|
||||||
|
]);
|
||||||
|
return { logs, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AuditLog> {
|
||||||
|
const log = await this.repo.findById(id);
|
||||||
|
if (!log) {
|
||||||
|
throw new NotFoundError(`Audit log '${id}' not found`);
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: {
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
resource: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}): Promise<AuditLog> {
|
||||||
|
return this.repo.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async purgeExpired(): Promise<number> {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - this.retentionDays);
|
||||||
|
return this.repo.deleteOlderThan(cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFilter(params?: AuditLogQueryParams): AuditLogFilter | undefined {
|
||||||
|
if (!params) return undefined;
|
||||||
|
const filter: AuditLogFilter = {};
|
||||||
|
|
||||||
|
if (params.userId !== undefined) filter.userId = params.userId;
|
||||||
|
if (params.action !== undefined) filter.action = params.action;
|
||||||
|
if (params.resource !== undefined) filter.resource = params.resource;
|
||||||
|
if (params.resourceId !== undefined) filter.resourceId = params.resourceId;
|
||||||
|
if (params.since !== undefined) filter.since = new Date(params.since);
|
||||||
|
if (params.until !== undefined) filter.until = new Date(params.until);
|
||||||
|
if (params.limit !== undefined) filter.limit = params.limit;
|
||||||
|
if (params.offset !== undefined) filter.offset = params.offset;
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@ export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config
|
|||||||
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
|
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
|
||||||
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
|
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
|
||||||
export { DockerContainerManager } from './docker/container-manager.js';
|
export { DockerContainerManager } from './docker/container-manager.js';
|
||||||
|
export { AuditLogService } from './audit-log.service.js';
|
||||||
|
export type { AuditLogQueryParams } from './audit-log.service.js';
|
||||||
|
|||||||
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