feat(api): add redacted audit read endpoints
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildApp } from '../../app.js';
|
||||
import { AuditQueryService } from '../../application/audit/audit-query-service.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import { registerAuditRoutes } from './audit-routes.js';
|
||||
|
||||
const dbs: Database.Database[] = [];
|
||||
const timestamp = '2026-07-17T10:00:00.000Z';
|
||||
afterEach(() => {
|
||||
for (const db of dbs.splice(0)) db.close();
|
||||
});
|
||||
function fixture() {
|
||||
const db = new Database(':memory:');
|
||||
migrateDatabase(db);
|
||||
dbs.push(db);
|
||||
const audit = new AuditQueryService(db);
|
||||
const app = buildApp({ registerRoutes: (scope) => registerAuditRoutes(scope, { audit }) });
|
||||
return { app, db, audit };
|
||||
}
|
||||
function insert(db: Database.Database, outcome = 'succeeded'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO audit_events
|
||||
(id, actor, operation_id, risk_level, request_id, parameters_summary_json,
|
||||
result_code, duration_ms, created_at)
|
||||
VALUES ('event-1', 'actor-1', 'op.one', 'R1', 'request-1', '[]', ?, 1, ?)`,
|
||||
).run(outcome, timestamp);
|
||||
}
|
||||
function exactProblem(
|
||||
response: {
|
||||
headers: Record<string, string | string[] | number | undefined>;
|
||||
json(): unknown;
|
||||
},
|
||||
values: Record<string, unknown>,
|
||||
): void {
|
||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||
expect(response.headers['x-request-id']).toEqual(expect.any(String));
|
||||
expect(response.json()).toEqual({
|
||||
type: 'about:blank',
|
||||
...values,
|
||||
requestId: response.headers['x-request-id'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('audit HTTP read routes', () => {
|
||||
it('lists and gets audit events with exact defaults', async () => {
|
||||
const { app, db, audit } = fixture();
|
||||
insert(db);
|
||||
const listCall = vi.spyOn(audit, 'list');
|
||||
const list = await app.inject({ method: 'GET', url: '/api/v1/audit' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(listCall).toHaveBeenCalledWith({ page: 1, pageSize: 25, direction: 'asc' });
|
||||
expect(list.json()).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'event-1',
|
||||
occurredAt: timestamp,
|
||||
actorId: 'actor-1',
|
||||
action: 'op.one',
|
||||
outcome: 'succeeded',
|
||||
requestId: 'request-1',
|
||||
parameterSummary: [],
|
||||
},
|
||||
],
|
||||
page: { page: 1, pageSize: 25, total: 1 },
|
||||
});
|
||||
const get = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' });
|
||||
expect(get.statusCode).toBe(200);
|
||||
expect(get.json()).toEqual(list.json().items[0]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('parses every allowlisted scalar query', async () => {
|
||||
const { app, audit } = fixture();
|
||||
const call = vi.spyOn(audit, 'list');
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/audit?page=2&pageSize=10&sort=action&direction=desc&actorId=a&instanceId=i&jobId=j&operationId=op&outcome=denied&occurredFrom=${timestamp}&occurredTo=${timestamp}&requestId=r`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(call).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
sort: 'action',
|
||||
direction: 'desc',
|
||||
actorId: 'a',
|
||||
instanceId: 'i',
|
||||
jobId: 'j',
|
||||
operationId: 'op',
|
||||
outcome: 'denied',
|
||||
occurredFrom: timestamp,
|
||||
occurredTo: timestamp,
|
||||
requestId: 'r',
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'unknown=x',
|
||||
'page=1&page=2',
|
||||
'actorId=a&actorId=b',
|
||||
'page=0',
|
||||
'page=01',
|
||||
'page=1.5',
|
||||
'page=9007199254740992',
|
||||
'pageSize=101',
|
||||
'sort=',
|
||||
'sort=id',
|
||||
'direction=ASC',
|
||||
'outcome=ok',
|
||||
'actorId=',
|
||||
'operationId=',
|
||||
'occurredFrom=bad',
|
||||
`occurredFrom=2026-07-17T11%3A00%3A00.000Z&occurredTo=${timestamp}`,
|
||||
])('rejects invalid, repeated, unknown, or noncanonical query: %s', async (query) => {
|
||||
const { app } = fixture();
|
||||
const response = await app.inject({ method: 'GET', url: `/api/v1/audit?${query}` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
exactProblem(response, {
|
||||
title: 'Bad Request',
|
||||
status: 400,
|
||||
code: 'VALIDATION_FAILED',
|
||||
detail: 'The audit query is invalid.',
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns exact redacted 404 and 503 problems', async () => {
|
||||
const { app, db } = fixture();
|
||||
const missing = await app.inject({ method: 'GET', url: '/api/v1/audit/missing' });
|
||||
exactProblem(missing, {
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
detail: 'The requested audit event does not exist.',
|
||||
});
|
||||
insert(db, 'secret-internal-result-code');
|
||||
const unavailable = await app.inject({ method: 'GET', url: '/api/v1/audit/event-1' });
|
||||
exactProblem(unavailable, {
|
||||
title: 'Service Unavailable',
|
||||
status: 503,
|
||||
code: 'UNREPRESENTABLE',
|
||||
detail: 'The requested audit data is temporarily unavailable.',
|
||||
});
|
||||
expect(unavailable.body).not.toContain('secret-internal-result-code');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { AUDIT_OUTCOMES, type AuditOutcome, type AuditPageQuery } from '@multi-simadmin/contracts';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { AuditQueryError, AuditQueryService } from '../../application/audit/audit-query-service.js';
|
||||
|
||||
export interface AuditRoutesOptions {
|
||||
readonly audit: AuditQueryService;
|
||||
}
|
||||
|
||||
const QUERY_KEYS = new Set([
|
||||
'page',
|
||||
'pageSize',
|
||||
'sort',
|
||||
'direction',
|
||||
'actorId',
|
||||
'instanceId',
|
||||
'jobId',
|
||||
'operationId',
|
||||
'outcome',
|
||||
'occurredFrom',
|
||||
'occurredTo',
|
||||
'requestId',
|
||||
]);
|
||||
const CANONICAL_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||
const MAX_STRING_LENGTH = 200;
|
||||
const responses = {
|
||||
VALIDATION_FAILED: {
|
||||
status: 400,
|
||||
title: 'Bad Request',
|
||||
detail: 'The audit query is invalid.',
|
||||
},
|
||||
NOT_FOUND: {
|
||||
status: 404,
|
||||
title: 'Not Found',
|
||||
detail: 'The requested audit event does not exist.',
|
||||
},
|
||||
UNREPRESENTABLE: {
|
||||
status: 503,
|
||||
title: 'Service Unavailable',
|
||||
detail: 'The requested audit data is temporarily unavailable.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function invalid(): never {
|
||||
throw new AuditQueryError('VALIDATION_FAILED', 'Invalid audit query');
|
||||
}
|
||||
function parseQuery(query: unknown): AuditPageQuery {
|
||||
if (query === null || typeof query !== 'object' || Array.isArray(query)) invalid();
|
||||
const input = query as Record<string, unknown>;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!QUERY_KEYS.has(key) || typeof input[key] !== 'string') invalid();
|
||||
}
|
||||
const scalar = (key: string): string | undefined => {
|
||||
const value = input[key];
|
||||
return value === undefined ? undefined : (value as string);
|
||||
};
|
||||
const integer = (key: string, fallback: number, maximum: number): number => {
|
||||
const value = scalar(key);
|
||||
if (value === undefined) return fallback;
|
||||
if (!/^[1-9]\d*$/u.test(value)) invalid();
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed > maximum) invalid();
|
||||
return parsed;
|
||||
};
|
||||
const pageSize = integer('pageSize', 25, 100);
|
||||
const maximumPage = Math.floor(Number.MAX_SAFE_INTEGER / pageSize) + 1;
|
||||
const result: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort?: 'occurredAt' | 'action' | 'outcome';
|
||||
direction: 'asc' | 'desc';
|
||||
actorId?: string;
|
||||
instanceId?: string;
|
||||
jobId?: string;
|
||||
operationId?: string;
|
||||
outcome?: AuditOutcome;
|
||||
occurredFrom?: string;
|
||||
occurredTo?: string;
|
||||
requestId?: string;
|
||||
} = { page: integer('page', 1, maximumPage), pageSize, direction: 'asc' };
|
||||
const sort = scalar('sort');
|
||||
if (sort !== undefined) {
|
||||
if (sort !== 'occurredAt' && sort !== 'action' && sort !== 'outcome') invalid();
|
||||
result.sort = sort;
|
||||
}
|
||||
const direction = scalar('direction');
|
||||
if (direction !== undefined) {
|
||||
if (direction !== 'asc' && direction !== 'desc') invalid();
|
||||
result.direction = direction;
|
||||
}
|
||||
const outcome = scalar('outcome');
|
||||
if (outcome !== undefined) {
|
||||
if (!(AUDIT_OUTCOMES as readonly string[]).includes(outcome)) invalid();
|
||||
result.outcome = outcome as AuditOutcome;
|
||||
}
|
||||
for (const key of ['actorId', 'instanceId', 'jobId', 'operationId', 'requestId'] as const) {
|
||||
const value = scalar(key);
|
||||
if (value !== undefined) {
|
||||
if (value.length < 1 || value.length > MAX_STRING_LENGTH) invalid();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
for (const key of ['occurredFrom', 'occurredTo'] as const) {
|
||||
const value = scalar(key);
|
||||
if (value !== undefined) {
|
||||
const parsed = new Date(value);
|
||||
if (
|
||||
!CANONICAL_TIMESTAMP.test(value) ||
|
||||
!Number.isFinite(parsed.getTime()) ||
|
||||
parsed.toISOString() !== value
|
||||
)
|
||||
invalid();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.occurredFrom !== undefined &&
|
||||
result.occurredTo !== undefined &&
|
||||
result.occurredFrom > result.occurredTo
|
||||
)
|
||||
invalid();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerAuditRoutes(app: FastifyInstance, options: AuditRoutesOptions): void {
|
||||
const handler =
|
||||
<T>(action: (request: FastifyRequest) => T) =>
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
return action(request);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuditQueryError)) throw error;
|
||||
const mapped = responses[error.code];
|
||||
return reply.code(mapped.status).type('application/problem+json').send({
|
||||
type: 'about:blank',
|
||||
title: mapped.title,
|
||||
status: mapped.status,
|
||||
code: error.code,
|
||||
detail: mapped.detail,
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
'/api/v1/audit',
|
||||
handler((request) => options.audit.list(parseQuery(request.query))),
|
||||
);
|
||||
app.get(
|
||||
'/api/v1/audit/:eventId',
|
||||
handler((request) => options.audit.get((request.params as { eventId: string }).eventId)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user