feat(api): advance capability and secure operations slices
This commit is contained in:
@@ -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