feat(operations): add audited runtime catalog
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../../app.js';
|
||||
import { operationCatalogRegistry } from '../../application/operations/operation-catalog-data.js';
|
||||
import { registerOperationRoutes } from './operation-routes.js';
|
||||
|
||||
const apps: ReturnType<typeof buildApp>[] = [];
|
||||
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
|
||||
const app = () => {
|
||||
const instance = buildApp({
|
||||
registerRoutes: (server) => registerOperationRoutes(server, operationCatalogRegistry),
|
||||
});
|
||||
apps.push(instance);
|
||||
return instance;
|
||||
};
|
||||
|
||||
describe('GET /api/v1/operations', () => {
|
||||
it('returns only the safe catalog projection', async () => {
|
||||
const response = await app().inject('/api/v1/operations?pageSize=1');
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ page: { page: 1, pageSize: 1, total: 117 } });
|
||||
expect(Object.keys(response.json().items[0])).toEqual([
|
||||
'operationId',
|
||||
'title',
|
||||
'risk',
|
||||
'capability',
|
||||
'batchable',
|
||||
'parameterSchemaId',
|
||||
]);
|
||||
for (const forbidden of [
|
||||
'method',
|
||||
'pathTemplate',
|
||||
'sourceEvidence',
|
||||
'sensitiveFields',
|
||||
'handler',
|
||||
])
|
||||
expect(response.body).not.toContain(`\"${forbidden}\"`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'unknown=x',
|
||||
'page=1&page=2',
|
||||
'page[]=1',
|
||||
'page=0',
|
||||
'page=01',
|
||||
'page=1.5',
|
||||
'pageSize=101',
|
||||
'risk=R4',
|
||||
'capability=bogus',
|
||||
'batchable=True',
|
||||
'batchable=1',
|
||||
'sort=title',
|
||||
'direction=down',
|
||||
`search=${'x'.repeat(201)}`,
|
||||
])('rejects invalid strict scalar query: %s', async (query) => {
|
||||
const response = await app().inject(`/api/v1/operations?${query}`);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||
expect(response.json()).toMatchObject({ code: 'VALIDATION_FAILED', status: 400 });
|
||||
});
|
||||
|
||||
it('accepts every frozen capability including job', async () => {
|
||||
const response = await app().inject('/api/v1/operations?capability=job&pageSize=100');
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().items.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
response.json().items.every((item: { capability: string }) => item.capability === 'job'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { OperationPageQuery } from '@multi-simadmin/contracts';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
|
||||
import type { OperationCatalogRegistry } from '../../application/operations/operation-catalog-service.js';
|
||||
|
||||
const capabilities = new Set(['query', 'command', 'job']);
|
||||
const keys = new Set([
|
||||
'page',
|
||||
'pageSize',
|
||||
'sort',
|
||||
'direction',
|
||||
'risk',
|
||||
'capability',
|
||||
'batchable',
|
||||
'search',
|
||||
]);
|
||||
class CatalogValidationError extends Error {}
|
||||
|
||||
function scalarQuery(request: FastifyRequest): Record<string, string> {
|
||||
const raw = request.query;
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
||||
throw new CatalogValidationError();
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!keys.has(key) || typeof value !== 'string') throw new CatalogValidationError();
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseQuery(request: FastifyRequest): OperationPageQuery {
|
||||
const query = scalarQuery(request);
|
||||
const result: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: 'operationId' | 'risk' | 'capability';
|
||||
direction?: 'asc' | 'desc';
|
||||
risk?: 'R0' | 'R1' | 'R2' | 'R3';
|
||||
capability?: string;
|
||||
batchable?: boolean;
|
||||
search?: string;
|
||||
} = {};
|
||||
const integer = (key: 'page' | 'pageSize', maximum?: number): void => {
|
||||
const value = query[key];
|
||||
if (value === undefined) return;
|
||||
if (!/^[1-9]\d*$/.test(value)) throw new CatalogValidationError();
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || (maximum !== undefined && parsed > maximum))
|
||||
throw new CatalogValidationError();
|
||||
result[key] = parsed;
|
||||
};
|
||||
integer('page');
|
||||
integer('pageSize', 100);
|
||||
const sort = query.sort;
|
||||
if (sort !== undefined) {
|
||||
if (sort !== 'operationId' && sort !== 'risk' && sort !== 'capability')
|
||||
throw new CatalogValidationError();
|
||||
result.sort = sort;
|
||||
}
|
||||
const direction = query.direction;
|
||||
if (direction !== undefined) {
|
||||
if (direction !== 'asc' && direction !== 'desc') throw new CatalogValidationError();
|
||||
result.direction = direction;
|
||||
}
|
||||
const risk = query.risk;
|
||||
if (risk !== undefined) {
|
||||
if (risk !== 'R0' && risk !== 'R1' && risk !== 'R2' && risk !== 'R3')
|
||||
throw new CatalogValidationError();
|
||||
result.risk = risk;
|
||||
}
|
||||
const capability = query.capability;
|
||||
if (capability !== undefined) {
|
||||
if (!capabilities.has(capability)) throw new CatalogValidationError();
|
||||
result.capability = capability;
|
||||
}
|
||||
const batchable = query.batchable;
|
||||
if (batchable !== undefined) {
|
||||
if (batchable !== 'true' && batchable !== 'false') throw new CatalogValidationError();
|
||||
result.batchable = batchable === 'true';
|
||||
}
|
||||
const search = query.search;
|
||||
if (search !== undefined) {
|
||||
if (search.length > 200) throw new CatalogValidationError();
|
||||
result.search = search;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function registerOperationRoutes(
|
||||
app: FastifyInstance,
|
||||
registry: OperationCatalogRegistry,
|
||||
): void {
|
||||
const service = new OperationCatalogService(registry);
|
||||
app.get('/api/v1/operations', async (request, reply) => {
|
||||
try {
|
||||
return service.list(parseQuery(request));
|
||||
} catch (error) {
|
||||
if (!(error instanceof CatalogValidationError)) throw error;
|
||||
return reply.code(400).type('application/problem+json').send({
|
||||
type: 'about:blank',
|
||||
title: 'Bad Request',
|
||||
status: 400,
|
||||
code: 'VALIDATION_FAILED',
|
||||
detail: 'The request did not satisfy the endpoint schema.',
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user