feat(api): advance capability and secure operations slices
This commit is contained in:
@@ -25,6 +25,7 @@ export interface InstanceRoutesOptions {
|
||||
readonly connections?: ConnectionProbe;
|
||||
readonly login?: InstanceLoginService;
|
||||
readonly deletion?: DeleteInstanceOperation;
|
||||
readonly registerDeletionPreparationRoute?: boolean;
|
||||
}
|
||||
const problem = (
|
||||
request: FastifyRequest,
|
||||
@@ -379,10 +380,12 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
||||
}
|
||||
|
||||
if (options.deletion) {
|
||||
app.post(
|
||||
'/api/v1/operations/prepare',
|
||||
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
||||
);
|
||||
if (options.registerDeletionPreparationRoute !== false) {
|
||||
app.post(
|
||||
'/api/v1/operations/prepare',
|
||||
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
||||
);
|
||||
}
|
||||
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
||||
const handled = await wrap(async (wrappedRequest) => {
|
||||
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { OperationPageQuery } from '@multi-simadmin/contracts';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
ExecuteOperationRequest,
|
||||
OperationPageQuery,
|
||||
PrepareOperationRequest,
|
||||
} from '@multi-simadmin/contracts';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
|
||||
import type { OperationCatalogRegistry } from '../../application/operations/operation-catalog-service.js';
|
||||
import {
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from '../../application/operations/delete-instance-operation.js';
|
||||
import {
|
||||
SecureOperationExecution,
|
||||
SecureOperationExecutionError,
|
||||
} from '../../application/operations/secure-operation-execution.js';
|
||||
|
||||
const capabilities = new Set(['query', 'command', 'job']);
|
||||
const keys = new Set([
|
||||
@@ -89,6 +101,8 @@ function parseQuery(request: FastifyRequest): OperationPageQuery {
|
||||
export function registerOperationRoutes(
|
||||
app: FastifyInstance,
|
||||
registry: OperationCatalogRegistry,
|
||||
execution?: SecureOperationExecution,
|
||||
deletion?: DeleteInstanceOperation,
|
||||
): void {
|
||||
const service = new OperationCatalogService(registry);
|
||||
app.get('/api/v1/operations', async (request, reply) => {
|
||||
@@ -106,4 +120,67 @@ export function registerOperationRoutes(
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!execution) return;
|
||||
const problem = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
error: SecureOperationExecutionError,
|
||||
) => {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 409 : 400;
|
||||
const title = status === 404 ? 'Not Found' : status === 409 ? 'Conflict' : 'Bad Request';
|
||||
return reply.code(status).type('application/problem+json').send({
|
||||
type: 'about:blank',
|
||||
title,
|
||||
status,
|
||||
code: error.code,
|
||||
detail: error.message,
|
||||
requestId: request.id,
|
||||
});
|
||||
};
|
||||
app.post('/api/v1/operations/prepare', async (request, reply) => {
|
||||
try {
|
||||
const body = request.body as { operationId?: unknown };
|
||||
if (body && typeof body === 'object' && body.operationId === 'deleteInstance' && deletion)
|
||||
return reply.code(200).send(await deletion.prepare(request.body as never, request.id));
|
||||
return reply
|
||||
.code(200)
|
||||
.send(await execution.prepare(request.body as PrepareOperationRequest, request.id));
|
||||
} catch (error) {
|
||||
if (error instanceof DeleteInstanceOperationError) {
|
||||
const status =
|
||||
error.code === 'NOT_FOUND' ? 404 : error.code === 'REVISION_CONFLICT' ? 412 : 400;
|
||||
return reply
|
||||
.code(status)
|
||||
.type('application/problem+json')
|
||||
.send({
|
||||
type: 'about:blank',
|
||||
title:
|
||||
status === 404 ? 'Not Found' : status === 412 ? 'Precondition Failed' : 'Bad Request',
|
||||
status,
|
||||
code: error.code,
|
||||
detail: 'The requested operation could not be prepared.',
|
||||
requestId: request.id,
|
||||
});
|
||||
}
|
||||
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
app.post('/api/v1/operations/execute', async (request, reply) => {
|
||||
try {
|
||||
return reply
|
||||
.code(202)
|
||||
.send(
|
||||
await execution.execute(
|
||||
request.body as ExecuteOperationRequest,
|
||||
'loopback-control-plane',
|
||||
request.id,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SecureOperationExecutionError) return problem(request, reply, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp } from '../../app.js';
|
||||
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||
import {
|
||||
SecureOperationExecution,
|
||||
secureOperationRegistry,
|
||||
} from '../../application/operations/secure-operation-execution.js';
|
||||
import { operationCatalogRegistry } from '../../application/operations/operation-catalog-data.js';
|
||||
import { registerOperationRoutes } from './operation-routes.js';
|
||||
|
||||
const resources: Array<{ close(): unknown }> = [];
|
||||
afterEach(async () => {
|
||||
for (const resource of resources.splice(0)) await resource.close();
|
||||
});
|
||||
function fixture() {
|
||||
const db = new Database(':memory:');
|
||||
resources.push(db);
|
||||
db.pragma('foreign_keys=ON');
|
||||
migrateDatabase(db);
|
||||
db.prepare(
|
||||
`INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
|
||||
VALUES ('i','one','http://192.168.1.10','none',1,1,'2026-07-17T00:00:00.000Z','2026-07-17T00:00:00.000Z')`,
|
||||
).run();
|
||||
const request = vi.fn(async () => ({ status: 200 }));
|
||||
const execution = new SecureOperationExecution({
|
||||
db,
|
||||
registry: secureOperationRegistry,
|
||||
transport: { request },
|
||||
idFactory: (() => {
|
||||
let n = 0;
|
||||
return () => `id-${++n}`;
|
||||
})(),
|
||||
tokenFactory: () => Buffer.alloc(32, 4).toString('base64url'),
|
||||
});
|
||||
const app = buildApp({
|
||||
registerRoutes: (server) =>
|
||||
registerOperationRoutes(server, operationCatalogRegistry, execution),
|
||||
});
|
||||
resources.push(app);
|
||||
return { app, db, request };
|
||||
}
|
||||
const prepareBody = {
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
targets: [{ instanceId: 'i', revision: 1 }],
|
||||
parameters: {
|
||||
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||
fields: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('secure operation routes', () => {
|
||||
it('returns 200 Preparation then 202 terminal Job with request IDs', async () => {
|
||||
const { app, db, request } = fixture();
|
||||
const prepared = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/prepare',
|
||||
payload: prepareBody,
|
||||
});
|
||||
expect(prepared.statusCode).toBe(200);
|
||||
expect(prepared.headers['x-request-id']).toBeTruthy();
|
||||
const preparation = prepared.json();
|
||||
expect(db.prepare('SELECT count(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||
const executed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/execute',
|
||||
payload: {
|
||||
preparationId: preparation.id,
|
||||
confirmationToken: preparation.confirmationToken,
|
||||
},
|
||||
});
|
||||
expect(executed.statusCode).toBe(202);
|
||||
expect(executed.headers['x-request-id']).toBeTruthy();
|
||||
expect(executed.json()).toMatchObject({
|
||||
operationId: 'postNetworkRegisterAuto',
|
||||
status: 'succeeded',
|
||||
});
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/api/v1/operations/prepare', { ...prepareBody, extra: true }],
|
||||
[
|
||||
'/api/v1/operations/prepare',
|
||||
{ ...prepareBody, targets: [{ instanceId: 'i', revision: 1, extra: true }] },
|
||||
],
|
||||
[
|
||||
'/api/v1/operations/prepare',
|
||||
{ ...prepareBody, parameters: { ...prepareBody.parameters, extra: true } },
|
||||
],
|
||||
['/api/v1/operations/execute', { preparationId: 'x', confirmationToken: 'x', extra: true }],
|
||||
])('strictly rejects additional properties at %s', async (url, payload) => {
|
||||
const { app } = fixture();
|
||||
const response = await app.inject({ method: 'POST', url, payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.headers['content-type']).toContain('application/problem+json');
|
||||
expect(response.headers['x-request-id']).toBeTruthy();
|
||||
expect(response.json()).toMatchObject({
|
||||
status: 400,
|
||||
code: 'VALIDATION_FAILED',
|
||||
requestId: response.headers['x-request-id'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Problem Details and does not consume a wrong confirmation', async () => {
|
||||
const { app, db } = fixture();
|
||||
const preparation = (
|
||||
await app.inject({ method: 'POST', url: '/api/v1/operations/prepare', payload: prepareBody })
|
||||
).json();
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/operations/execute',
|
||||
payload: { preparationId: preparation.id, confirmationToken: 'wrong' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ status: 400, code: 'CONFIRMATION_INVALID' });
|
||||
expect(
|
||||
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(preparation.id),
|
||||
).toEqual({ status: 'prepared' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user