feat(api): advance capability and secure operations slices
This commit is contained in:
@@ -0,0 +1,258 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
|
||||||
|
import {
|
||||||
|
CapabilityObservationError,
|
||||||
|
CapabilityObservationService,
|
||||||
|
parseHealthVersion,
|
||||||
|
type CapabilityOperationDescriptor,
|
||||||
|
} from './capability-observation-service.js';
|
||||||
|
|
||||||
|
const databases: Database.Database[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const database of databases.splice(0)) database.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const operation = (
|
||||||
|
overrides: Partial<CapabilityOperationDescriptor> = {},
|
||||||
|
): CapabilityOperationDescriptor => ({
|
||||||
|
operationId: 'getHealth',
|
||||||
|
method: 'GET',
|
||||||
|
pathTemplate: '/api/health',
|
||||||
|
riskLevel: 'R0',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(
|
||||||
|
options: {
|
||||||
|
descriptor?: CapabilityOperationDescriptor;
|
||||||
|
response?: {
|
||||||
|
status: number;
|
||||||
|
headers: Readonly<Record<string, string | undefined>>;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
failure?: unknown;
|
||||||
|
now?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys=ON');
|
||||||
|
migrateDatabase(db);
|
||||||
|
databases.push(db);
|
||||||
|
const created = '2026-07-17T10:00:00.000Z';
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
).run('instance-1', 'LAN', 'http://192.168.1.20:3000', 'none', 1, 1, created, created);
|
||||||
|
const events: string[] = [];
|
||||||
|
const instances = {
|
||||||
|
get: vi.fn(async (id: string) => {
|
||||||
|
events.push('instance');
|
||||||
|
return id === 'instance-1' ? { id, origin: 'http://192.168.1.20:3000' } : undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const registry = {
|
||||||
|
requireOperation: vi.fn((id: string) => {
|
||||||
|
events.push('operation');
|
||||||
|
if (id !== 'getHealth') throw new Error('unknown operation');
|
||||||
|
return options.descriptor ?? operation();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const transport = {
|
||||||
|
get: vi.fn(async (url: string) => {
|
||||||
|
events.push('network');
|
||||||
|
expect(url).toBe('http://192.168.1.20:3000/api/health');
|
||||||
|
if (options.failure) throw options.failure;
|
||||||
|
return options.response ?? { status: 200, headers: {}, body: '{"version":"1.2.3"}' };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new CapabilityObservationService({
|
||||||
|
db,
|
||||||
|
instances,
|
||||||
|
registry,
|
||||||
|
transport,
|
||||||
|
now: () => new Date(options.now ?? '2026-07-17T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
return { db, events, instances, registry, transport, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (db: Database.Database) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
'SELECT instance_id,operation_id,state,upstream_version,detail_code,observed_at,created_at,updated_at FROM capabilities',
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
describe('parseHealthVersion', () => {
|
||||||
|
it.each([
|
||||||
|
['{"version":"1.2.3"}', '1.2.3'],
|
||||||
|
['{"data":{"version":"v2026.07.17-beta+5"}}', 'v2026.07.17-beta+5'],
|
||||||
|
['{"version":" 2.0.0 "}', '2.0.0'],
|
||||||
|
])('extracts a bounded version from supported health envelopes', (body, expected) => {
|
||||||
|
expect(parseHealthVersion(body)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not-json'],
|
||||||
|
['null'],
|
||||||
|
['{"version":7}'],
|
||||||
|
['{"version":""}'],
|
||||||
|
[`{"version":"${'a'.repeat(129)}"}`],
|
||||||
|
['{"version":"1.2.3\\nsecret"}'],
|
||||||
|
['{"data":{"version":{"raw":"secret"}}}'],
|
||||||
|
])('rejects malformed, unbounded, or unsafe values without leaking them', (body) => {
|
||||||
|
expect(parseHealthVersion(body)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CapabilityObservationService', () => {
|
||||||
|
it('resolves instance then operation before probing an R0 GET and persists one redacted supported row', async () => {
|
||||||
|
const { db, events, service } = fixture();
|
||||||
|
await expect(service.observe('instance-1', 'getHealth')).resolves.toEqual({
|
||||||
|
instanceId: 'instance-1',
|
||||||
|
operationId: 'getHealth',
|
||||||
|
state: 'supported',
|
||||||
|
upstreamVersion: '1.2.3',
|
||||||
|
detailCode: 'ACTIVE_PROBE_SUPPORTED',
|
||||||
|
observedAt: '2026-07-17T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(events).toEqual(['instance', 'operation', 'network']);
|
||||||
|
expect(row(db)).toEqual({
|
||||||
|
instance_id: 'instance-1',
|
||||||
|
operation_id: 'getHealth',
|
||||||
|
state: 'supported',
|
||||||
|
upstream_version: '1.2.3',
|
||||||
|
detail_code: 'ACTIVE_PROBE_SUPPORTED',
|
||||||
|
observed_at: '2026-07-17T12:00:00.000Z',
|
||||||
|
created_at: '2026-07-17T12:00:00.000Z',
|
||||||
|
updated_at: '2026-07-17T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[401, 'auth-required', 'ACTIVE_PROBE_AUTH_REQUIRED'],
|
||||||
|
[403, 'auth-required', 'ACTIVE_PROBE_AUTH_REQUIRED'],
|
||||||
|
[404, 'unsupported', 'ACTIVE_PROBE_UNSUPPORTED'],
|
||||||
|
[500, 'degraded', 'ACTIVE_PROBE_UPSTREAM_DEGRADED'],
|
||||||
|
[503, 'degraded', 'ACTIVE_PROBE_UPSTREAM_DEGRADED'],
|
||||||
|
] as const)(
|
||||||
|
'classifies HTTP %i without persisting response content',
|
||||||
|
async (status, state, detailCode) => {
|
||||||
|
const body = '{"password":"do-not-store","version":"9.9.9"}';
|
||||||
|
const { db, service } = fixture({
|
||||||
|
response: { status, headers: { 'set-cookie': 'secret=1' }, body },
|
||||||
|
});
|
||||||
|
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
|
||||||
|
state,
|
||||||
|
detailCode,
|
||||||
|
upstreamVersion: undefined,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(row(db))).not.toContain('do-not-store');
|
||||||
|
expect(JSON.stringify(row(db))).not.toContain('secret=1');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[new UpstreamError('UPSTREAM_TIMEOUT'), 'ACTIVE_PROBE_TIMEOUT'],
|
||||||
|
[new UpstreamError('UPSTREAM_UNAVAILABLE'), 'ACTIVE_PROBE_NETWORK_ERROR'],
|
||||||
|
[new Error('password=never-persist'), 'ACTIVE_PROBE_NETWORK_ERROR'],
|
||||||
|
])('maps transport failures to unknown stable codes only', async (failure, detailCode) => {
|
||||||
|
const { db, service } = fixture({ failure });
|
||||||
|
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
|
||||||
|
state: 'unknown',
|
||||||
|
detailCode,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(row(db))).not.toContain(failure.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[operation({ operationId: 'getHealth', method: 'POST', riskLevel: 'R1' })],
|
||||||
|
[operation({ operationId: 'getHealth', method: 'DELETE', riskLevel: 'R3' })],
|
||||||
|
[operation({ operationId: 'getHealth', method: 'GET', riskLevel: 'R1' })],
|
||||||
|
])('never actively probes non-R0-GET operations and records unknown', async (descriptor) => {
|
||||||
|
const { db, service, transport } = fixture({ descriptor });
|
||||||
|
await expect(service.observe('instance-1', 'getHealth')).resolves.toMatchObject({
|
||||||
|
state: 'unknown',
|
||||||
|
detailCode: 'ACTIVE_PROBE_NOT_SAFE',
|
||||||
|
upstreamVersion: undefined,
|
||||||
|
});
|
||||||
|
expect(transport.get).not.toHaveBeenCalled();
|
||||||
|
expect(row(db)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let an older in-flight observation overwrite a newer completed observation', async () => {
|
||||||
|
const { db, service, transport } = fixture();
|
||||||
|
let resolveOlder!: (response: {
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
}) => void;
|
||||||
|
const olderResponse = new Promise<{
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
}>((resolve) => {
|
||||||
|
resolveOlder = resolve;
|
||||||
|
});
|
||||||
|
transport.get
|
||||||
|
.mockImplementationOnce(async () => olderResponse)
|
||||||
|
.mockResolvedValueOnce({ status: 404, headers: {}, body: 'newer-sensitive-body' });
|
||||||
|
|
||||||
|
const older = service.observe('instance-1', 'getHealth');
|
||||||
|
await vi.waitFor(() => expect(transport.get).toHaveBeenCalledTimes(1));
|
||||||
|
const newer = service.observe('instance-1', 'getHealth');
|
||||||
|
await expect(newer).resolves.toMatchObject({
|
||||||
|
state: 'unsupported',
|
||||||
|
detailCode: 'ACTIVE_PROBE_UNSUPPORTED',
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveOlder({ status: 200, headers: {}, body: '{"version":"older-secret-version"}' });
|
||||||
|
await expect(older).resolves.toMatchObject({ state: 'supported' });
|
||||||
|
expect(row(db)).toMatchObject({
|
||||||
|
state: 'unsupported',
|
||||||
|
upstream_version: null,
|
||||||
|
detail_code: 'ACTIVE_PROBE_UNSUPPORTED',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(row(db))).not.toContain('older-secret-version');
|
||||||
|
expect(JSON.stringify(row(db))).not.toContain('newer-sensitive-body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts the single instance-operation row while preserving created_at', async () => {
|
||||||
|
const first = fixture();
|
||||||
|
await first.service.observe('instance-1', 'getHealth');
|
||||||
|
const second = new CapabilityObservationService({
|
||||||
|
db: first.db,
|
||||||
|
instances: first.instances,
|
||||||
|
registry: first.registry,
|
||||||
|
transport: { get: async () => ({ status: 404, headers: {}, body: 'sensitive' }) },
|
||||||
|
now: () => new Date('2026-07-17T13:00:00.000Z'),
|
||||||
|
});
|
||||||
|
await second.observe('instance-1', 'getHealth');
|
||||||
|
expect(first.db.prepare('SELECT COUNT(*) count FROM capabilities').get()).toEqual({ count: 1 });
|
||||||
|
expect(row(first.db)).toMatchObject({
|
||||||
|
state: 'unsupported',
|
||||||
|
upstream_version: null,
|
||||||
|
created_at: '2026-07-17T12:00:00.000Z',
|
||||||
|
updated_at: '2026-07-17T13:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does no operation resolution, write, or network for an unknown instance', async () => {
|
||||||
|
const { db, service, registry, transport } = fixture();
|
||||||
|
await expect(service.observe('missing', 'getHealth')).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<CapabilityObservationError>>({ code: 'INSTANCE_NOT_FOUND' }),
|
||||||
|
);
|
||||||
|
expect(registry.requireOperation).not.toHaveBeenCalled();
|
||||||
|
expect(transport.get).not.toHaveBeenCalled();
|
||||||
|
expect(row(db)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does no write or network for an unknown operation', async () => {
|
||||||
|
const { db, service, transport } = fixture();
|
||||||
|
await expect(service.observe('instance-1', 'missing')).rejects.toEqual(
|
||||||
|
expect.objectContaining<Partial<CapabilityObservationError>>({ code: 'OPERATION_NOT_FOUND' }),
|
||||||
|
);
|
||||||
|
expect(transport.get).not.toHaveBeenCalled();
|
||||||
|
expect(row(db)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { UpstreamError } from '../../infrastructure/transport/upstream-error.js';
|
||||||
|
import type { TransportResponse } from '../../infrastructure/transport/safe-instance-transport.js';
|
||||||
|
|
||||||
|
export type CapabilityState =
|
||||||
|
| 'supported'
|
||||||
|
| 'unsupported'
|
||||||
|
| 'auth-required'
|
||||||
|
| 'degraded'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type CapabilityDetailCode =
|
||||||
|
| 'ACTIVE_PROBE_SUPPORTED'
|
||||||
|
| 'ACTIVE_PROBE_AUTH_REQUIRED'
|
||||||
|
| 'ACTIVE_PROBE_UNSUPPORTED'
|
||||||
|
| 'ACTIVE_PROBE_UPSTREAM_DEGRADED'
|
||||||
|
| 'ACTIVE_PROBE_TIMEOUT'
|
||||||
|
| 'ACTIVE_PROBE_NETWORK_ERROR'
|
||||||
|
| 'ACTIVE_PROBE_UNEXPECTED_STATUS'
|
||||||
|
| 'ACTIVE_PROBE_NOT_SAFE';
|
||||||
|
|
||||||
|
export interface CapabilityOperationDescriptor {
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly method: string;
|
||||||
|
readonly pathTemplate: string;
|
||||||
|
readonly riskLevel: 'R0' | 'R1' | 'R2' | 'R3';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityInstance {
|
||||||
|
readonly id: string;
|
||||||
|
readonly origin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityObservation {
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly state: CapabilityState;
|
||||||
|
readonly upstreamVersion: string | undefined;
|
||||||
|
readonly detailCode: CapabilityDetailCode;
|
||||||
|
readonly observedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityObservationOptions {
|
||||||
|
readonly db: Database.Database;
|
||||||
|
readonly instances: { get(instanceId: string): Promise<CapabilityInstance | undefined> };
|
||||||
|
readonly registry: { requireOperation(operationId: string): CapabilityOperationDescriptor };
|
||||||
|
readonly transport: { get(url: string): Promise<TransportResponse> };
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CapabilityObservationErrorCode = 'INSTANCE_NOT_FOUND' | 'OPERATION_NOT_FOUND';
|
||||||
|
|
||||||
|
export class CapabilityObservationError extends Error {
|
||||||
|
constructor(readonly code: CapabilityObservationErrorCode) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'CapabilityObservationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VERSION_LENGTH = 128;
|
||||||
|
const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
||||||
|
|
||||||
|
/** Maps only documented health envelope locations into bounded, inert version metadata. */
|
||||||
|
export function parseHealthVersion(body: string): string | undefined {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
const root = value as Record<string, unknown>;
|
||||||
|
const data = root.data;
|
||||||
|
const candidate =
|
||||||
|
root.version ??
|
||||||
|
(data !== null && typeof data === 'object' && !Array.isArray(data)
|
||||||
|
? (data as Record<string, unknown>).version
|
||||||
|
: undefined);
|
||||||
|
if (typeof candidate !== 'string') return undefined;
|
||||||
|
const normalized = candidate.trim();
|
||||||
|
return normalized.length > 0 &&
|
||||||
|
normalized.length <= MAX_VERSION_LENGTH &&
|
||||||
|
VERSION_PATTERN.test(normalized)
|
||||||
|
? normalized
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinUrl = (origin: string, path: string): string =>
|
||||||
|
`${origin.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
||||||
|
|
||||||
|
function classifyStatus(status: number): {
|
||||||
|
state: CapabilityState;
|
||||||
|
detailCode: CapabilityDetailCode;
|
||||||
|
} {
|
||||||
|
if (status >= 200 && status < 300)
|
||||||
|
return { state: 'supported', detailCode: 'ACTIVE_PROBE_SUPPORTED' };
|
||||||
|
if (status === 401 || status === 403)
|
||||||
|
return { state: 'auth-required', detailCode: 'ACTIVE_PROBE_AUTH_REQUIRED' };
|
||||||
|
if (status === 404) return { state: 'unsupported', detailCode: 'ACTIVE_PROBE_UNSUPPORTED' };
|
||||||
|
if (status >= 500 && status < 600)
|
||||||
|
return { state: 'degraded', detailCode: 'ACTIVE_PROBE_UPSTREAM_DEGRADED' };
|
||||||
|
return { state: 'unknown', detailCode: 'ACTIVE_PROBE_UNEXPECTED_STATUS' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CapabilityObservationService {
|
||||||
|
readonly #db: Database.Database;
|
||||||
|
readonly #instances: CapabilityObservationOptions['instances'];
|
||||||
|
readonly #registry: CapabilityObservationOptions['registry'];
|
||||||
|
readonly #transport: CapabilityObservationOptions['transport'];
|
||||||
|
readonly #now: () => Date;
|
||||||
|
readonly #latestGeneration = new Map<string, number>();
|
||||||
|
#nextGeneration = 0;
|
||||||
|
|
||||||
|
constructor(options: CapabilityObservationOptions) {
|
||||||
|
this.#db = options.db;
|
||||||
|
this.#instances = options.instances;
|
||||||
|
this.#registry = options.registry;
|
||||||
|
this.#transport = options.transport;
|
||||||
|
this.#now = options.now ?? (() => new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(instanceId: string, operationId: string): Promise<CapabilityObservation> {
|
||||||
|
// Resolution order is intentional: neither unknown identity crosses the network or persistence boundary.
|
||||||
|
const instance = await this.#instances.get(instanceId);
|
||||||
|
if (!instance) throw new CapabilityObservationError('INSTANCE_NOT_FOUND');
|
||||||
|
|
||||||
|
let operation: CapabilityOperationDescriptor;
|
||||||
|
try {
|
||||||
|
operation = this.#registry.requireOperation(operationId);
|
||||||
|
} catch {
|
||||||
|
throw new CapabilityObservationError('OPERATION_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
const observationKey = JSON.stringify([instanceId, operation.operationId]);
|
||||||
|
const generation = ++this.#nextGeneration;
|
||||||
|
this.#latestGeneration.set(observationKey, generation);
|
||||||
|
const observedAt = this.#now().toISOString();
|
||||||
|
if (operation.method !== 'GET' || operation.riskLevel !== 'R0') {
|
||||||
|
return this.#persist(
|
||||||
|
{
|
||||||
|
instanceId,
|
||||||
|
operationId: operation.operationId,
|
||||||
|
state: 'unknown',
|
||||||
|
upstreamVersion: undefined,
|
||||||
|
detailCode: 'ACTIVE_PROBE_NOT_SAFE',
|
||||||
|
observedAt,
|
||||||
|
},
|
||||||
|
observationKey,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: CapabilityState;
|
||||||
|
let detailCode: CapabilityDetailCode;
|
||||||
|
let upstreamVersion: string | undefined;
|
||||||
|
try {
|
||||||
|
const response = await this.#transport.get(joinUrl(instance.origin, operation.pathTemplate));
|
||||||
|
({ state, detailCode } = classifyStatus(response.status));
|
||||||
|
if (state === 'supported' && operation.operationId === 'getHealth') {
|
||||||
|
upstreamVersion = parseHealthVersion(response.body);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state = 'unknown';
|
||||||
|
detailCode =
|
||||||
|
error instanceof UpstreamError && error.code === 'UPSTREAM_TIMEOUT'
|
||||||
|
? 'ACTIVE_PROBE_TIMEOUT'
|
||||||
|
: 'ACTIVE_PROBE_NETWORK_ERROR';
|
||||||
|
}
|
||||||
|
return this.#persist(
|
||||||
|
{
|
||||||
|
instanceId,
|
||||||
|
operationId: operation.operationId,
|
||||||
|
state,
|
||||||
|
upstreamVersion,
|
||||||
|
detailCode,
|
||||||
|
observedAt,
|
||||||
|
},
|
||||||
|
observationKey,
|
||||||
|
generation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#persist(
|
||||||
|
observation: CapabilityObservation,
|
||||||
|
observationKey: string,
|
||||||
|
generation: number,
|
||||||
|
): CapabilityObservation {
|
||||||
|
if (this.#latestGeneration.get(observationKey) !== generation) {
|
||||||
|
return Object.freeze({ ...observation });
|
||||||
|
}
|
||||||
|
this.#db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO capabilities
|
||||||
|
(instance_id,operation_id,state,upstream_version,detail_code,observed_at,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(instance_id,operation_id) DO UPDATE SET
|
||||||
|
state=excluded.state,
|
||||||
|
upstream_version=excluded.upstream_version,
|
||||||
|
detail_code=excluded.detail_code,
|
||||||
|
observed_at=excluded.observed_at,
|
||||||
|
updated_at=excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
observation.instanceId,
|
||||||
|
observation.operationId,
|
||||||
|
observation.state,
|
||||||
|
observation.upstreamVersion ?? null,
|
||||||
|
observation.detailCode,
|
||||||
|
observation.observedAt,
|
||||||
|
observation.observedAt,
|
||||||
|
observation.observedAt,
|
||||||
|
);
|
||||||
|
this.#latestGeneration.delete(observationKey);
|
||||||
|
return Object.freeze({ ...observation });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,78 @@ describe('DeleteInstanceOperation', () => {
|
|||||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['risk_level', 'R2'],
|
||||||
|
['target_origin', 'http://192.168.1.99'],
|
||||||
|
['method', 'POST'],
|
||||||
|
['path', '/api/v1/instances/someone-else'],
|
||||||
|
['canonical_query', 'force=true'],
|
||||||
|
['body_digest', 'a'.repeat(64)],
|
||||||
|
['content_type', 'application/json'],
|
||||||
|
['nonce', 'tampered-nonce'],
|
||||||
|
])(
|
||||||
|
'fails closed and invalidates a confirmation with tampered durable %s binding',
|
||||||
|
async (column, value) => {
|
||||||
|
const { db, instances, operation } = fixture();
|
||||||
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
const prepared = await operation.prepare({
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||||
|
});
|
||||||
|
db.prepare(`UPDATE operation_preparations SET ${column}=? WHERE id=?`).run(
|
||||||
|
value,
|
||||||
|
prepared.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
operation.executeDelete({
|
||||||
|
instanceId: instance.id,
|
||||||
|
revision: 1,
|
||||||
|
preparationId: prepared.id,
|
||||||
|
confirmationToken: prepared.confirmationToken,
|
||||||
|
actor: 'loopback-control-plane',
|
||||||
|
requestId: 'tampered-binding',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||||
|
expect(await instances.get(instance.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||||
|
).toEqual({ status: 'invalidated' });
|
||||||
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('fails closed when the current target origin no longer matches the durable binding', async () => {
|
||||||
|
const { db, instances, operation } = fixture();
|
||||||
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
const prepared = await operation.prepare({
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||||
|
});
|
||||||
|
db.prepare('UPDATE instances SET base_url=? WHERE id=?').run(
|
||||||
|
'http://192.168.1.11',
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
operation.executeDelete({
|
||||||
|
instanceId: instance.id,
|
||||||
|
revision: 1,
|
||||||
|
preparationId: prepared.id,
|
||||||
|
confirmationToken: prepared.confirmationToken,
|
||||||
|
actor: 'loopback-control-plane',
|
||||||
|
requestId: 'changed-origin',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||||
|
expect(await instances.get(instance.id)).toBeDefined();
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||||
|
).toEqual({ status: 'invalidated' });
|
||||||
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
it('returns typed target errors after consuming a correctly bound confirmation', async () => {
|
it('returns typed target errors after consuming a correctly bound confirmation', async () => {
|
||||||
const { db, instances, operation } = fixture();
|
const { db, instances, operation } = fixture();
|
||||||
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
|||||||
@@ -29,11 +29,19 @@ export class DeleteInstanceOperationError extends Error {
|
|||||||
|
|
||||||
interface PreparationRow {
|
interface PreparationRow {
|
||||||
operation_id: string;
|
operation_id: string;
|
||||||
|
risk_level: string;
|
||||||
status: string;
|
status: string;
|
||||||
target_instance_id: string;
|
target_instance_id: string;
|
||||||
target_revision: number;
|
target_revision: number;
|
||||||
|
target_origin: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
canonical_query: string;
|
||||||
|
body_digest: string;
|
||||||
|
content_type: string;
|
||||||
parameter_schema_id: string;
|
parameter_schema_id: string;
|
||||||
parameters_digest: string;
|
parameters_digest: string;
|
||||||
|
nonce: string;
|
||||||
token_digest: string;
|
token_digest: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
@@ -128,8 +136,10 @@ export class DeleteInstanceOperation {
|
|||||||
this.db
|
this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO operation_preparations
|
`INSERT INTO operation_preparations
|
||||||
(id,operation_id,risk_level,status,target_instance_id,target_revision,parameter_schema_id,parameters_digest,token_digest,requested_by,request_id,expires_at,created_at,updated_at)
|
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
|
||||||
|
requested_by,request_id,expires_at,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -138,8 +148,15 @@ export class DeleteInstanceOperation {
|
|||||||
'prepared',
|
'prepared',
|
||||||
target.instanceId,
|
target.instanceId,
|
||||||
target.revision,
|
target.revision,
|
||||||
|
current.origin,
|
||||||
|
'DELETE',
|
||||||
|
`/api/v1/instances/${target.instanceId}`,
|
||||||
|
'',
|
||||||
|
digest(''),
|
||||||
|
'',
|
||||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||||
PARAMETERS_DIGEST,
|
PARAMETERS_DIGEST,
|
||||||
|
`delete-${id}`,
|
||||||
digest(token),
|
digest(token),
|
||||||
ACTOR,
|
ACTOR,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -169,8 +186,9 @@ export class DeleteInstanceOperation {
|
|||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT operation_id,status,target_instance_id,target_revision,parameter_schema_id,
|
`SELECT operation_id,risk_level,status,target_instance_id,target_revision,target_origin,
|
||||||
parameters_digest,token_digest,expires_at
|
method,path,canonical_query,body_digest,content_type,parameter_schema_id,
|
||||||
|
parameters_digest,nonce,token_digest,expires_at
|
||||||
FROM operation_preparations WHERE id=?`,
|
FROM operation_preparations WHERE id=?`,
|
||||||
)
|
)
|
||||||
.get(input.preparationId) as PreparationRow | undefined;
|
.get(input.preparationId) as PreparationRow | undefined;
|
||||||
@@ -180,15 +198,44 @@ export class DeleteInstanceOperation {
|
|||||||
'Confirmation could not be accepted',
|
'Confirmation could not be accepted',
|
||||||
);
|
);
|
||||||
|
|
||||||
const secretValid =
|
const confirmationValid =
|
||||||
row.expires_at > now &&
|
row.expires_at > now && equalDigest(row.token_digest, digest(input.confirmationToken));
|
||||||
equalDigest(row.token_digest, digest(input.confirmationToken)) &&
|
if (!confirmationValid) return;
|
||||||
|
|
||||||
|
const expectedPath = `/api/v1/instances/${input.instanceId}`;
|
||||||
|
const bindingValid =
|
||||||
row.operation_id === OPERATION_ID &&
|
row.operation_id === OPERATION_ID &&
|
||||||
|
row.risk_level === 'R3' &&
|
||||||
row.target_instance_id === input.instanceId &&
|
row.target_instance_id === input.instanceId &&
|
||||||
row.target_revision === input.revision &&
|
row.target_revision === input.revision &&
|
||||||
|
row.method === 'DELETE' &&
|
||||||
|
row.path === expectedPath &&
|
||||||
|
row.canonical_query === '' &&
|
||||||
|
row.body_digest === digest('') &&
|
||||||
|
row.content_type === '' &&
|
||||||
row.parameter_schema_id === DELETE_INSTANCE_PARAMETER_SCHEMA_ID &&
|
row.parameter_schema_id === DELETE_INSTANCE_PARAMETER_SCHEMA_ID &&
|
||||||
row.parameters_digest === PARAMETERS_DIGEST;
|
row.parameters_digest === PARAMETERS_DIGEST &&
|
||||||
if (!secretValid) return;
|
row.nonce === `delete-${input.preparationId}`;
|
||||||
|
if (!bindingValid) {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||||
|
)
|
||||||
|
.run(now, now, input.preparationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.db
|
||||||
|
.prepare('SELECT base_url,config_revision FROM instances WHERE id=?')
|
||||||
|
.get(input.instanceId) as { base_url: string; config_revision: number } | undefined;
|
||||||
|
if (current && current.base_url !== row.target_origin) {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||||
|
)
|
||||||
|
.run(now, now, input.preparationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// A correctly authenticated confirmation is one-shot, including stale/missing target results.
|
// A correctly authenticated confirmation is one-shot, including stale/missing target results.
|
||||||
this.db
|
this.db
|
||||||
@@ -196,9 +243,6 @@ export class DeleteInstanceOperation {
|
|||||||
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||||
)
|
)
|
||||||
.run(now, now, input.preparationId);
|
.run(now, now, input.preparationId);
|
||||||
const current = this.db
|
|
||||||
.prepare('SELECT config_revision FROM instances WHERE id=?')
|
|
||||||
.get(input.instanceId) as { config_revision: number } | undefined;
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
targetError = 'NOT_FOUND';
|
targetError = 'NOT_FOUND';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
||||||
|
import {
|
||||||
|
OperationNotDispatchedError,
|
||||||
|
SecureOperationExecution,
|
||||||
|
secureOperationRegistry,
|
||||||
|
type SafeOperationTransport,
|
||||||
|
} from './secure-operation-execution.js';
|
||||||
|
|
||||||
|
const SCHEMA = 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1';
|
||||||
|
const databases: Database.Database[] = [];
|
||||||
|
afterEach(() => databases.splice(0).forEach((db) => db.close()));
|
||||||
|
|
||||||
|
function fixture(transport?: SafeOperationTransport) {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys=ON');
|
||||||
|
migrateDatabase(db);
|
||||||
|
databases.push(db);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO instances
|
||||||
|
(id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at)
|
||||||
|
VALUES ('i-1','one','http://192.168.1.10','none',1,3,'2026-07-17T00:00:00.000Z','2026-07-17T00:00:00.000Z')`,
|
||||||
|
).run();
|
||||||
|
let sequence = 0;
|
||||||
|
const request = vi.fn(async () => ({ status: 204 }));
|
||||||
|
const execution = new SecureOperationExecution({
|
||||||
|
db,
|
||||||
|
registry: secureOperationRegistry,
|
||||||
|
transport: transport ?? { request },
|
||||||
|
now: () => new Date('2026-07-17T12:00:00.000Z'),
|
||||||
|
idFactory: () => `id-${++sequence}`,
|
||||||
|
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
|
||||||
|
nonceFactory: () => `nonce-${++sequence}`,
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
operationId: 'postNetworkRegisterAuto',
|
||||||
|
targets: [{ instanceId: 'i-1', revision: 3 }],
|
||||||
|
parameters: { parameterSchemaId: SCHEMA, fields: [] },
|
||||||
|
} as const;
|
||||||
|
return { db, execution, input, request };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectCode = async (promise: Promise<unknown>, code: string) =>
|
||||||
|
expect(promise).rejects.toMatchObject({ code });
|
||||||
|
|
||||||
|
describe('SecureOperationExecution generic R2 slice', () => {
|
||||||
|
it('prepares the sole audited zero-parameter operation without upstream or a job and persists full binding plus digest only', async () => {
|
||||||
|
const { db, execution, input, request } = fixture();
|
||||||
|
const prepared = await execution.prepare(input, 'request-1');
|
||||||
|
expect(prepared).toMatchObject({
|
||||||
|
operationId: input.operationId,
|
||||||
|
risk: 'R2',
|
||||||
|
status: 'prepared',
|
||||||
|
targetCount: 1,
|
||||||
|
});
|
||||||
|
expect(prepared.expiresAt).toBe('2026-07-17T12:05:00.000Z');
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
expect(db.prepare('SELECT count(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT * FROM operation_preparations WHERE id=?')
|
||||||
|
.get(prepared.id) as Record<string, unknown>;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
target_instance_id: 'i-1',
|
||||||
|
target_revision: 3,
|
||||||
|
target_origin: 'http://192.168.1.10',
|
||||||
|
operation_id: input.operationId,
|
||||||
|
risk_level: 'R2',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/network/register-auto',
|
||||||
|
canonical_query: '',
|
||||||
|
body_digest: createHash('sha256').update('').digest('hex'),
|
||||||
|
content_type: '',
|
||||||
|
parameter_schema_id: SCHEMA,
|
||||||
|
status: 'prepared',
|
||||||
|
});
|
||||||
|
expect(row.token_digest).toBe(
|
||||||
|
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(row)).not.toContain(prepared.confirmationToken);
|
||||||
|
expect(typeof row.nonce).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strictly rejects unknown shapes, operations, parameters, targets, and stale revisions', async () => {
|
||||||
|
const { execution, input } = fixture();
|
||||||
|
await expectCode(execution.prepare({ ...input, extra: true } as never), 'VALIDATION_FAILED');
|
||||||
|
await expectCode(
|
||||||
|
execution.prepare({ ...input, operationId: 'getDevice' }),
|
||||||
|
'OPERATION_NOT_ALLOWED',
|
||||||
|
);
|
||||||
|
await expectCode(execution.prepare({ ...input, targets: [] }), 'VALIDATION_FAILED');
|
||||||
|
await expectCode(
|
||||||
|
execution.prepare({ ...input, targets: [{ instanceId: 'i-1', revision: 2 }] }),
|
||||||
|
'REVISION_CONFLICT',
|
||||||
|
);
|
||||||
|
await expectCode(
|
||||||
|
execution.prepare({
|
||||||
|
...input,
|
||||||
|
parameters: { ...input.parameters, fields: [{ fieldId: 'x', kind: 'string', value: 'x' }] },
|
||||||
|
} as never),
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not consume on wrong token, expires atomically, and consumes exactly once after re-resolving bindings', async () => {
|
||||||
|
const { db, execution, input, request } = fixture();
|
||||||
|
const prepared = await execution.prepare(input);
|
||||||
|
await expectCode(
|
||||||
|
execution.execute({ preparationId: prepared.id, confirmationToken: 'wrong' }, 'actor', 'r2'),
|
||||||
|
'CONFIRMATION_INVALID',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||||
|
).toEqual({ status: 'prepared' });
|
||||||
|
const job = await execution.execute(
|
||||||
|
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||||
|
'actor',
|
||||||
|
'r3',
|
||||||
|
);
|
||||||
|
expect(job).toMatchObject({
|
||||||
|
operationId: input.operationId,
|
||||||
|
status: 'succeeded',
|
||||||
|
items: [{ targetId: 'i-1', state: 'succeeded' }],
|
||||||
|
});
|
||||||
|
expect(request).toHaveBeenCalledWith({
|
||||||
|
origin: 'http://192.168.1.10',
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/network/register-auto',
|
||||||
|
query: '',
|
||||||
|
contentType: '',
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
await expectCode(
|
||||||
|
execution.execute(
|
||||||
|
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||||
|
'actor',
|
||||||
|
'r4',
|
||||||
|
),
|
||||||
|
'CONFIRMATION_INVALID',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||||
|
).toEqual({ status: 'consumed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an expired preparation expired without transport', async () => {
|
||||||
|
const { db, execution, input, request } = fixture();
|
||||||
|
const prepared = await execution.prepare(input);
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE operation_preparations SET expires_at='2020-01-01T00:00:00.000Z' WHERE id=?",
|
||||||
|
).run(prepared.id);
|
||||||
|
await expectCode(
|
||||||
|
execution.execute(
|
||||||
|
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||||
|
'a',
|
||||||
|
'r',
|
||||||
|
),
|
||||||
|
'CONFIRMATION_INVALID',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
||||||
|
).toEqual({ status: 'expired' });
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('durably creates running lineage before transport and maps ambiguity/interruption to unknown-result', async () => {
|
||||||
|
let observed = false;
|
||||||
|
const { db, execution, input } = fixture({
|
||||||
|
request: async () => {
|
||||||
|
observed =
|
||||||
|
(
|
||||||
|
db.prepare("SELECT count(*) count FROM jobs WHERE status='running'").get() as {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
).count === 1;
|
||||||
|
throw new Error('socket reset');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await execution.prepare(input);
|
||||||
|
const job = await execution.execute(
|
||||||
|
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||||
|
'a',
|
||||||
|
'r',
|
||||||
|
);
|
||||||
|
expect(observed).toBe(true);
|
||||||
|
expect(job.status).toBe('unknown-result');
|
||||||
|
expect(job.attempts[0]?.state).toBe('unknown-result');
|
||||||
|
expect(execution.reconcileInterruptedJobs()).toBe(0);
|
||||||
|
db.prepare("UPDATE jobs SET status='running',finished_at=NULL").run();
|
||||||
|
db.prepare("UPDATE job_items SET status='running',finished_at=NULL").run();
|
||||||
|
db.prepare("UPDATE job_attempts SET status='running',finished_at=NULL").run();
|
||||||
|
expect(execution.reconcileInterruptedJobs()).toBe(1);
|
||||||
|
expect(db.prepare('SELECT status FROM jobs').get()).toEqual({ status: 'unknown-result' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a deterministic pre-dispatch rejection to failed rather than unknown-result', async () => {
|
||||||
|
const { execution, input, db } = fixture({
|
||||||
|
request: async () => {
|
||||||
|
throw new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const prepared = await execution.prepare(input);
|
||||||
|
const job = await execution.execute(
|
||||||
|
{ preparationId: prepared.id, confirmationToken: prepared.confirmationToken },
|
||||||
|
'a',
|
||||||
|
'r',
|
||||||
|
);
|
||||||
|
expect(job.status).toBe('failed');
|
||||||
|
expect(job.items[0]?.state).toBe('failed');
|
||||||
|
expect(db.prepare('SELECT result_code FROM job_items').get()).toEqual({
|
||||||
|
result_code: 'UPSTREAM_NOT_DISPATCHED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
ExecuteOperationRequest,
|
||||||
|
Job,
|
||||||
|
Preparation,
|
||||||
|
PrepareOperationRequest,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
export interface SecureOperationDescriptor {
|
||||||
|
readonly operationId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly riskLevel: string;
|
||||||
|
readonly method: string;
|
||||||
|
readonly pathTemplate: string;
|
||||||
|
readonly requestContentType: string;
|
||||||
|
readonly parameterSchemaId: string;
|
||||||
|
}
|
||||||
|
export interface SecureOperationRegistry {
|
||||||
|
requireExecutableOperation(operationId: string): SecureOperationDescriptor;
|
||||||
|
}
|
||||||
|
export const secureOperationRegistry: SecureOperationRegistry = {
|
||||||
|
requireExecutableOperation(operationId) {
|
||||||
|
if (operationId !== 'postNetworkRegisterAuto') throw new Error('UNKNOWN_OPERATION');
|
||||||
|
return {
|
||||||
|
operationId: 'postNetworkRegisterAuto',
|
||||||
|
title: 'Register Network Automatically',
|
||||||
|
riskLevel: 'R2',
|
||||||
|
method: 'POST',
|
||||||
|
pathTemplate: '/api/network/register-auto',
|
||||||
|
requestContentType: 'none',
|
||||||
|
parameterSchemaId: 'simadmin.58e2204.postNetworkRegisterAuto.parameters.v1',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_OPERATION = 'postNetworkRegisterAuto';
|
||||||
|
const ACTOR = 'loopback-control-plane';
|
||||||
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
|
const EMPTY_DIGEST = createHash('sha256').update('').digest('hex');
|
||||||
|
|
||||||
|
export interface SafeOperationTransportRequest {
|
||||||
|
readonly origin: string;
|
||||||
|
readonly method: 'POST';
|
||||||
|
readonly path: string;
|
||||||
|
readonly query: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly body: undefined;
|
||||||
|
}
|
||||||
|
export interface SafeOperationTransport {
|
||||||
|
request(request: SafeOperationTransportRequest): Promise<{ readonly status: number }>;
|
||||||
|
}
|
||||||
|
/** The request was rejected before any bytes could have reached the upstream. */
|
||||||
|
export class OperationNotDispatchedError extends Error {
|
||||||
|
readonly dispatched = false;
|
||||||
|
constructor(readonly code: string) {
|
||||||
|
super(code);
|
||||||
|
this.name = 'OperationNotDispatchedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type SecureOperationExecutionErrorCode =
|
||||||
|
| 'VALIDATION_FAILED'
|
||||||
|
| 'OPERATION_NOT_ALLOWED'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'REVISION_CONFLICT'
|
||||||
|
| 'CONFIRMATION_INVALID';
|
||||||
|
export class SecureOperationExecutionError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: SecureOperationExecutionErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SecureOperationExecutionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface Options {
|
||||||
|
readonly db: Database.Database;
|
||||||
|
readonly registry: SecureOperationRegistry;
|
||||||
|
readonly transport: SafeOperationTransport;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
readonly idFactory?: () => string;
|
||||||
|
readonly tokenFactory?: () => string;
|
||||||
|
readonly nonceFactory?: () => string;
|
||||||
|
}
|
||||||
|
interface PreparationRow {
|
||||||
|
operation_id: string;
|
||||||
|
risk_level: string;
|
||||||
|
status: string;
|
||||||
|
target_instance_id: string;
|
||||||
|
target_revision: number;
|
||||||
|
target_origin: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
canonical_query: string;
|
||||||
|
body_digest: string;
|
||||||
|
content_type: string;
|
||||||
|
parameter_schema_id: string;
|
||||||
|
parameters_digest: string;
|
||||||
|
nonce: string;
|
||||||
|
token_digest: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
const digest = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex');
|
||||||
|
const equalDigest = (left: string, right: string): boolean => {
|
||||||
|
const a = Buffer.from(left, 'hex');
|
||||||
|
const b = Buffer.from(right, 'hex');
|
||||||
|
return a.length === 32 && b.length === 32 && timingSafeEqual(a, b);
|
||||||
|
};
|
||||||
|
const validRevision = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||||
|
const parameterDigest = (schema: string): string =>
|
||||||
|
digest(JSON.stringify({ parameterSchemaId: schema, fields: [] }));
|
||||||
|
|
||||||
|
export class SecureOperationExecution {
|
||||||
|
private readonly clock: () => Date;
|
||||||
|
private readonly id: () => string;
|
||||||
|
private readonly token: () => string;
|
||||||
|
private readonly nonce: () => string;
|
||||||
|
constructor(private readonly options: Options) {
|
||||||
|
this.clock = options.now ?? (() => new Date());
|
||||||
|
this.id = options.idFactory ?? randomUUID;
|
||||||
|
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
|
||||||
|
this.nonce = options.nonceFactory ?? randomUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(input: PrepareOperationRequest, requestId = this.id()): Promise<Preparation> {
|
||||||
|
if (
|
||||||
|
!input ||
|
||||||
|
typeof input !== 'object' ||
|
||||||
|
Array.isArray(input) ||
|
||||||
|
Object.keys(input).some((key) => !['operationId', 'targets', 'parameters'].includes(key)) ||
|
||||||
|
typeof input.operationId !== 'string'
|
||||||
|
)
|
||||||
|
this.validation();
|
||||||
|
if (input.operationId !== ALLOWED_OPERATION)
|
||||||
|
throw new SecureOperationExecutionError(
|
||||||
|
'OPERATION_NOT_ALLOWED',
|
||||||
|
'Operation is not enabled for generic execution',
|
||||||
|
);
|
||||||
|
let descriptor: SecureOperationDescriptor;
|
||||||
|
try {
|
||||||
|
descriptor = this.options.registry.requireExecutableOperation(input.operationId);
|
||||||
|
} catch {
|
||||||
|
throw new SecureOperationExecutionError(
|
||||||
|
'OPERATION_NOT_ALLOWED',
|
||||||
|
'Operation is not enabled for generic execution',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
descriptor.riskLevel !== 'R2' ||
|
||||||
|
descriptor.method !== 'POST' ||
|
||||||
|
descriptor.pathTemplate !== '/api/network/register-auto' ||
|
||||||
|
descriptor.requestContentType !== 'none' ||
|
||||||
|
!Array.isArray(input.targets) ||
|
||||||
|
input.targets.length !== 1
|
||||||
|
)
|
||||||
|
this.validation();
|
||||||
|
const target = input.targets[0];
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
typeof target !== 'object' ||
|
||||||
|
Array.isArray(target) ||
|
||||||
|
Object.keys(target).some((key) => !['instanceId', 'revision'].includes(key)) ||
|
||||||
|
typeof target.instanceId !== 'string' ||
|
||||||
|
!target.instanceId ||
|
||||||
|
!validRevision(target.revision)
|
||||||
|
)
|
||||||
|
this.validation();
|
||||||
|
const parameters = input.parameters;
|
||||||
|
if (
|
||||||
|
!parameters ||
|
||||||
|
typeof parameters !== 'object' ||
|
||||||
|
Array.isArray(parameters) ||
|
||||||
|
Object.keys(parameters).some((key) => !['parameterSchemaId', 'fields'].includes(key)) ||
|
||||||
|
parameters.parameterSchemaId !== descriptor.parameterSchemaId ||
|
||||||
|
!Array.isArray(parameters.fields) ||
|
||||||
|
parameters.fields.length !== 0
|
||||||
|
)
|
||||||
|
this.validation();
|
||||||
|
const instance = this.options.db
|
||||||
|
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||||
|
.get(target.instanceId) as { base_url: string; config_revision: number } | undefined;
|
||||||
|
if (!instance) throw new SecureOperationExecutionError('NOT_FOUND', 'Instance was not found');
|
||||||
|
if (instance.config_revision !== target.revision)
|
||||||
|
throw new SecureOperationExecutionError(
|
||||||
|
'REVISION_CONFLICT',
|
||||||
|
'Instance revision does not match',
|
||||||
|
);
|
||||||
|
const token = this.token();
|
||||||
|
let tokenBytes: Buffer;
|
||||||
|
try {
|
||||||
|
tokenBytes = Buffer.from(token, 'base64url');
|
||||||
|
} catch {
|
||||||
|
tokenBytes = Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
if (tokenBytes.length < 32 || token.length < 20 || !/^[A-Za-z0-9_-]+$/.test(token))
|
||||||
|
throw new Error('Confirmation token factory returned an unsafe token');
|
||||||
|
const id = this.id();
|
||||||
|
const created = this.clock();
|
||||||
|
const now = created.toISOString();
|
||||||
|
const expiresAt = new Date(created.getTime() + TTL_MS).toISOString();
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO operation_preparations
|
||||||
|
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
|
||||||
|
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
|
||||||
|
requested_by,request_id,expires_at,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,'prepared',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
descriptor.operationId,
|
||||||
|
descriptor.riskLevel,
|
||||||
|
target.instanceId,
|
||||||
|
target.revision,
|
||||||
|
instance.base_url,
|
||||||
|
descriptor.method,
|
||||||
|
descriptor.pathTemplate,
|
||||||
|
'',
|
||||||
|
EMPTY_DIGEST,
|
||||||
|
'',
|
||||||
|
descriptor.parameterSchemaId,
|
||||||
|
parameterDigest(descriptor.parameterSchemaId),
|
||||||
|
this.nonce(),
|
||||||
|
digest(token),
|
||||||
|
ACTOR,
|
||||||
|
requestId,
|
||||||
|
expiresAt,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status: 'prepared',
|
||||||
|
operationId: descriptor.operationId,
|
||||||
|
risk: 'R2',
|
||||||
|
expiresAt,
|
||||||
|
confirmationToken: token,
|
||||||
|
confirmationPrompt: `Execute ${descriptor.title} on ${target.instanceId}?`,
|
||||||
|
targetCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(input: ExecuteOperationRequest, actor: string, requestId: string): Promise<Job> {
|
||||||
|
if (
|
||||||
|
!input ||
|
||||||
|
typeof input !== 'object' ||
|
||||||
|
Array.isArray(input) ||
|
||||||
|
Object.keys(input).some((key) => !['preparationId', 'confirmationToken'].includes(key)) ||
|
||||||
|
typeof input.preparationId !== 'string' ||
|
||||||
|
!input.preparationId ||
|
||||||
|
typeof input.confirmationToken !== 'string' ||
|
||||||
|
!input.confirmationToken
|
||||||
|
)
|
||||||
|
this.validation();
|
||||||
|
const now = this.clock().toISOString();
|
||||||
|
const ids = { job: this.id(), item: this.id(), attempt: this.id() };
|
||||||
|
let bound: PreparationRow | undefined;
|
||||||
|
const outcome = this.options.db.transaction((): 'accepted' | 'invalid' => {
|
||||||
|
const row = this.options.db
|
||||||
|
.prepare('SELECT * FROM operation_preparations WHERE id=?')
|
||||||
|
.get(input.preparationId) as PreparationRow | undefined;
|
||||||
|
if (!row || row.status !== 'prepared') return 'invalid';
|
||||||
|
if (row.expires_at <= now) {
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE operation_preparations SET status='expired',updated_at=? WHERE id=? AND status='prepared'",
|
||||||
|
)
|
||||||
|
.run(now, input.preparationId);
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (!equalDigest(row.token_digest, digest(input.confirmationToken))) return 'invalid';
|
||||||
|
let descriptor: SecureOperationDescriptor;
|
||||||
|
try {
|
||||||
|
descriptor = this.options.registry.requireExecutableOperation(row.operation_id);
|
||||||
|
} catch {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
const instance = this.options.db
|
||||||
|
.prepare('SELECT base_url,config_revision FROM instances WHERE id=? AND enabled=1')
|
||||||
|
.get(row.target_instance_id) as { base_url: string; config_revision: number } | undefined;
|
||||||
|
const bindingValid =
|
||||||
|
descriptor.operationId === ALLOWED_OPERATION &&
|
||||||
|
descriptor.riskLevel === 'R2' &&
|
||||||
|
descriptor.method === row.method &&
|
||||||
|
descriptor.pathTemplate === row.path &&
|
||||||
|
descriptor.parameterSchemaId === row.parameter_schema_id &&
|
||||||
|
row.canonical_query === '' &&
|
||||||
|
row.content_type === '' &&
|
||||||
|
row.body_digest === EMPTY_DIGEST &&
|
||||||
|
row.parameters_digest === parameterDigest(row.parameter_schema_id) &&
|
||||||
|
!!row.nonce &&
|
||||||
|
!!instance &&
|
||||||
|
instance.base_url === row.target_origin &&
|
||||||
|
instance.config_revision === row.target_revision;
|
||||||
|
if (!bindingValid) {
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE operation_preparations SET status='invalidated',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||||
|
)
|
||||||
|
.run(now, now, input.preparationId);
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
const consumed = this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE operation_preparations SET status='consumed',consumed_at=?,updated_at=? WHERE id=? AND status='prepared'",
|
||||||
|
)
|
||||||
|
.run(now, now, input.preparationId);
|
||||||
|
if (consumed.changes !== 1) return 'invalid';
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO jobs
|
||||||
|
(id,parent_job_id,root_job_id,retry_of_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at)
|
||||||
|
VALUES (?,NULL,?,NULL,?,'R2','running',?,?,?,?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
ids.job,
|
||||||
|
ids.job,
|
||||||
|
row.operation_id,
|
||||||
|
actor,
|
||||||
|
requestId,
|
||||||
|
row.parameters_digest,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES (?,?,'running',?,?)",
|
||||||
|
)
|
||||||
|
.run(ids.attempt, ids.job, now, now);
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at)
|
||||||
|
VALUES (?,?,?,1,'running',?,?,?)`,
|
||||||
|
)
|
||||||
|
.run(ids.item, ids.job, row.target_instance_id, now, now, now);
|
||||||
|
bound = row;
|
||||||
|
return 'accepted';
|
||||||
|
})();
|
||||||
|
if (outcome !== 'accepted' || !bound)
|
||||||
|
throw new SecureOperationExecutionError(
|
||||||
|
'CONFIRMATION_INVALID',
|
||||||
|
'Confirmation could not be accepted',
|
||||||
|
);
|
||||||
|
let state: 'succeeded' | 'failed' | 'unknown-result';
|
||||||
|
let code: string;
|
||||||
|
try {
|
||||||
|
const response = await this.options.transport.request({
|
||||||
|
origin: bound.target_origin,
|
||||||
|
method: 'POST',
|
||||||
|
path: bound.path,
|
||||||
|
query: '',
|
||||||
|
contentType: '',
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
state = response.status >= 200 && response.status < 300 ? 'succeeded' : 'failed';
|
||||||
|
code = state === 'succeeded' ? 'UPSTREAM_SUCCEEDED' : 'UPSTREAM_REJECTED';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OperationNotDispatchedError) {
|
||||||
|
state = 'failed';
|
||||||
|
code = 'UPSTREAM_NOT_DISPATCHED';
|
||||||
|
} else {
|
||||||
|
state = 'unknown-result';
|
||||||
|
code = 'UPSTREAM_RESULT_UNKNOWN';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.finish(ids, state, code);
|
||||||
|
return this.job(ids.job);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileInterruptedJobs(): number {
|
||||||
|
const now = this.clock().toISOString();
|
||||||
|
return this.options.db.transaction(() => {
|
||||||
|
const jobs = this.options.db
|
||||||
|
.prepare(
|
||||||
|
"SELECT id FROM jobs WHERE operation_id=? AND risk_level='R2' AND status='running'",
|
||||||
|
)
|
||||||
|
.all(ALLOWED_OPERATION) as Array<{ id: string }>;
|
||||||
|
for (const row of jobs) this.finishByJob(row.id, now, 'unknown-result', 'INTERRUPTED');
|
||||||
|
return jobs.length;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
private finish(
|
||||||
|
ids: { job: string; item: string; attempt: string },
|
||||||
|
state: string,
|
||||||
|
code: string,
|
||||||
|
): void {
|
||||||
|
const now = this.clock().toISOString();
|
||||||
|
this.options.db.transaction(() => {
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(state, code, now, now, ids.item);
|
||||||
|
this.options.db
|
||||||
|
.prepare("UPDATE job_attempts SET status=?,finished_at=? WHERE id=? AND status='running'")
|
||||||
|
.run(state, now, ids.attempt);
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(state, now, now, ids.job);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
private finishByJob(jobId: string, now: string, state: string, code: string): void {
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE job_items SET status=?,result_code=?,finished_at=?,updated_at=? WHERE job_id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(state, code, now, now, jobId);
|
||||||
|
this.options.db
|
||||||
|
.prepare("UPDATE job_attempts SET status=?,finished_at=? WHERE job_id=? AND status='running'")
|
||||||
|
.run(state, now, jobId);
|
||||||
|
this.options.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE jobs SET status=?,finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(state, now, now, jobId);
|
||||||
|
}
|
||||||
|
private job(id: string): Job {
|
||||||
|
const row = this.options.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
|
||||||
|
)
|
||||||
|
.get(id) as {
|
||||||
|
operation_id: string;
|
||||||
|
status: Job['status'];
|
||||||
|
root_job_id: string;
|
||||||
|
retry_of_job_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
const items = this.options.db
|
||||||
|
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=?')
|
||||||
|
.all(id) as Array<{
|
||||||
|
id: string;
|
||||||
|
instance_id: string;
|
||||||
|
status: 'succeeded' | 'failed' | 'unknown-result';
|
||||||
|
}>;
|
||||||
|
const attempts = this.options.db
|
||||||
|
.prepare('SELECT id,status,started_at,finished_at FROM job_attempts WHERE job_id=?')
|
||||||
|
.all(id) as Array<{
|
||||||
|
id: string;
|
||||||
|
status: 'succeeded' | 'failed' | 'unknown-result';
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
}>;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
operationId: row.operation_id,
|
||||||
|
status: row.status,
|
||||||
|
rootJobId: row.root_job_id,
|
||||||
|
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
|
||||||
|
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
|
||||||
|
attempts: attempts.map((attempt) => ({
|
||||||
|
id: attempt.id,
|
||||||
|
state: attempt.status,
|
||||||
|
startedAt: attempt.started_at,
|
||||||
|
...(attempt.finished_at ? { finishedAt: attempt.finished_at } : {}),
|
||||||
|
})),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private validation(): never {
|
||||||
|
throw new SecureOperationExecutionError('VALIDATION_FAILED', 'Invalid operation request');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { StatusPollScheduler } from './status-poll-scheduler.js';
|
||||||
|
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve!: () => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StatusPollScheduler', () => {
|
||||||
|
it('starts registered keys in FIFO order while respecting the global concurrency bound', async () => {
|
||||||
|
const runs: Array<{ id: string; done: ReturnType<typeof deferred> }> = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 1_000,
|
||||||
|
concurrency: 2,
|
||||||
|
task: (id) => {
|
||||||
|
const done = deferred();
|
||||||
|
runs.push({ id, done });
|
||||||
|
return done.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.add('b');
|
||||||
|
scheduler.add('c');
|
||||||
|
|
||||||
|
scheduler.start();
|
||||||
|
expect(runs.map(({ id }) => id)).toEqual(['a', 'b']);
|
||||||
|
runs[0]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(runs.map(({ id }) => id)).toEqual(['a', 'b', 'c']);
|
||||||
|
|
||||||
|
runs[1]!.done.resolve();
|
||||||
|
runs[2]!.done.resolve();
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains exactly one recurring timer chain when tasks resolve before the cadence', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const task = vi.fn(async () => undefined);
|
||||||
|
const scheduler = new StatusPollScheduler({ intervalMs: 10, concurrency: 1, task });
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.start();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
for (let expectedCalls = 2; expectedCalls <= 5; expectedCalls++) {
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
expect(task).toHaveBeenCalledTimes(expectedCalls);
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await scheduler.stop();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is single-flight per key and coalesces arbitrarily many interval ticks into one rerun', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const runs: ReturnType<typeof deferred>[] = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 10,
|
||||||
|
concurrency: 3,
|
||||||
|
task: async () => {
|
||||||
|
const done = deferred();
|
||||||
|
runs.push(done);
|
||||||
|
await done.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.start();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(runs).toHaveLength(1);
|
||||||
|
runs[0]!.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(runs).toHaveLength(2);
|
||||||
|
|
||||||
|
runs[1]!.resolve();
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps equal-due recurring work FIFO instead of letting a busy key starve queued keys', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const runs: Array<{ id: string; done: ReturnType<typeof deferred> }> = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 10,
|
||||||
|
concurrency: 1,
|
||||||
|
task: async (id) => {
|
||||||
|
const done = deferred();
|
||||||
|
runs.push({ id, done });
|
||||||
|
await done.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.add('b');
|
||||||
|
scheduler.start();
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
runs[0]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(runs.map(({ id }) => id)).toEqual(['a', 'b']);
|
||||||
|
|
||||||
|
runs[1]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(runs.map(({ id }) => id)).toEqual(['a', 'b', 'a']);
|
||||||
|
runs[2]!.done.resolve();
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports per-key interval updates and fences the obsolete generation', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const calls: Array<{ signal: AbortSignal; done: ReturnType<typeof deferred> }> = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 100,
|
||||||
|
concurrency: 1,
|
||||||
|
task: async (_id, signal) => {
|
||||||
|
const done = deferred();
|
||||||
|
calls.push({ signal, done });
|
||||||
|
await done.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.start();
|
||||||
|
scheduler.update('a', 10);
|
||||||
|
expect(calls[0]!.signal.aborted).toBe(true);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
calls[0]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
|
||||||
|
calls[1]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
calls[2]!.done.resolve();
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove cancels queued work and aborts active work; re-add is a fresh fenced generation', async () => {
|
||||||
|
const calls: Array<{ id: string; signal: AbortSignal; done: ReturnType<typeof deferred> }> = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 100,
|
||||||
|
concurrency: 1,
|
||||||
|
task: async (id, signal) => {
|
||||||
|
const done = deferred();
|
||||||
|
calls.push({ id, signal, done });
|
||||||
|
await done.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.add('b');
|
||||||
|
scheduler.start();
|
||||||
|
scheduler.remove('b');
|
||||||
|
scheduler.remove('a');
|
||||||
|
expect(calls[0]!.signal.aborted).toBe(true);
|
||||||
|
scheduler.add('a');
|
||||||
|
|
||||||
|
calls[0]!.done.resolve();
|
||||||
|
await flush();
|
||||||
|
expect(calls.map(({ id }) => id)).toEqual(['a', 'a']);
|
||||||
|
calls[1]!.done.resolve();
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases slots after rejection without an unhandled rejection', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const unhandled = vi.fn();
|
||||||
|
process.on('unhandledRejection', unhandled);
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 1_000,
|
||||||
|
concurrency: 1,
|
||||||
|
task: async (id) => {
|
||||||
|
calls.push(id);
|
||||||
|
if (id === 'a') throw new Error('probe failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.add('b');
|
||||||
|
scheduler.start();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toEqual(['a', 'b']);
|
||||||
|
expect(unhandled).not.toHaveBeenCalled();
|
||||||
|
process.off('unhandledRejection', unhandled);
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('start is idempotent and add/update reject invalid registration values', async () => {
|
||||||
|
const task = vi.fn(async () => undefined);
|
||||||
|
const scheduler = new StatusPollScheduler({ intervalMs: 20, concurrency: 1, task });
|
||||||
|
expect(() => scheduler.add('')).toThrow();
|
||||||
|
expect(() => scheduler.add('a', 0)).toThrow();
|
||||||
|
expect(() => scheduler.update('missing', 10)).toThrow();
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.start();
|
||||||
|
scheduler.start();
|
||||||
|
await flush();
|
||||||
|
expect(task).toHaveBeenCalledTimes(1);
|
||||||
|
await scheduler.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop is idempotent, clears timers, aborts active tasks, waits for settlement, and starts no queued work', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const active = deferred();
|
||||||
|
const calls: Array<{ id: string; signal: AbortSignal }> = [];
|
||||||
|
const scheduler = new StatusPollScheduler({
|
||||||
|
intervalMs: 10,
|
||||||
|
concurrency: 1,
|
||||||
|
task: async (id, signal) => {
|
||||||
|
calls.push({ id, signal });
|
||||||
|
await active.promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
scheduler.add('a');
|
||||||
|
scheduler.add('b');
|
||||||
|
scheduler.start();
|
||||||
|
const stopped = scheduler.stop();
|
||||||
|
let settled = false;
|
||||||
|
void stopped.then(() => {
|
||||||
|
settled = true;
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.signal.aborted).toBe(true);
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
|
||||||
|
active.resolve();
|
||||||
|
await stopped;
|
||||||
|
await scheduler.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
export type StatusPollTask = (instanceId: string, signal: AbortSignal) => Promise<void>;
|
||||||
|
|
||||||
|
export interface StatusPollSchedulerOptions {
|
||||||
|
task: StatusPollTask;
|
||||||
|
intervalMs: number;
|
||||||
|
concurrency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Registration {
|
||||||
|
generation: number;
|
||||||
|
intervalMs: number;
|
||||||
|
timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
instanceId: string;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveRun {
|
||||||
|
generation: number;
|
||||||
|
controller: AbortController;
|
||||||
|
promise: Promise<void>;
|
||||||
|
rerunGeneration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bounded, keyed polling scheduler. Registrations are the queue: repeated
|
||||||
|
* ticks only set one rerun bit, so backlog cannot grow with time.
|
||||||
|
*/
|
||||||
|
export class StatusPollScheduler {
|
||||||
|
readonly #task: StatusPollTask;
|
||||||
|
readonly #defaultIntervalMs: number;
|
||||||
|
readonly #concurrency: number;
|
||||||
|
readonly #registrations = new Map<string, Registration>();
|
||||||
|
readonly #queue: QueueItem[] = [];
|
||||||
|
readonly #queuedGeneration = new Map<string, number>();
|
||||||
|
readonly #active = new Map<string, ActiveRun>();
|
||||||
|
#nextGeneration = 1;
|
||||||
|
#started = false;
|
||||||
|
#stopping = false;
|
||||||
|
#stopPromise?: Promise<void>;
|
||||||
|
|
||||||
|
constructor(options: StatusPollSchedulerOptions) {
|
||||||
|
this.#defaultIntervalMs = this.#validInterval(options.intervalMs);
|
||||||
|
if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
|
||||||
|
throw new RangeError('concurrency must be a positive integer');
|
||||||
|
}
|
||||||
|
this.#concurrency = options.concurrency;
|
||||||
|
this.#task = options.task;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(instanceId: string, intervalMs = this.#defaultIntervalMs): void {
|
||||||
|
this.#validId(instanceId);
|
||||||
|
const interval = this.#validInterval(intervalMs);
|
||||||
|
if (this.#registrations.has(instanceId)) {
|
||||||
|
throw new Error(`instance is already registered: ${instanceId}`);
|
||||||
|
}
|
||||||
|
if (this.#stopping) throw new Error('scheduler is stopping');
|
||||||
|
|
||||||
|
const registration: Registration = {
|
||||||
|
generation: this.#nextGeneration++,
|
||||||
|
intervalMs: interval,
|
||||||
|
timer: undefined,
|
||||||
|
};
|
||||||
|
this.#registrations.set(instanceId, registration);
|
||||||
|
if (this.#started) this.#enqueue(instanceId, registration.generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(instanceId: string, intervalMs: number): void {
|
||||||
|
const interval = this.#validInterval(intervalMs);
|
||||||
|
const previous = this.#registrations.get(instanceId);
|
||||||
|
if (!previous) throw new Error(`instance is not registered: ${instanceId}`);
|
||||||
|
if (this.#stopping) throw new Error('scheduler is stopping');
|
||||||
|
|
||||||
|
if (previous.timer !== undefined) clearTimeout(previous.timer);
|
||||||
|
this.#cancelQueued(instanceId);
|
||||||
|
const active = this.#active.get(instanceId);
|
||||||
|
active?.controller.abort();
|
||||||
|
|
||||||
|
const registration: Registration = {
|
||||||
|
generation: this.#nextGeneration++,
|
||||||
|
intervalMs: interval,
|
||||||
|
timer: undefined,
|
||||||
|
};
|
||||||
|
this.#registrations.set(instanceId, registration);
|
||||||
|
if (this.#started) this.#enqueue(instanceId, registration.generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(instanceId: string): boolean {
|
||||||
|
const registration = this.#registrations.get(instanceId);
|
||||||
|
if (!registration) return false;
|
||||||
|
this.#registrations.delete(instanceId);
|
||||||
|
if (registration.timer !== undefined) clearTimeout(registration.timer);
|
||||||
|
this.#cancelQueued(instanceId);
|
||||||
|
this.#active.get(instanceId)?.controller.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.#started || this.#stopping) return;
|
||||||
|
this.#started = true;
|
||||||
|
for (const [instanceId, registration] of this.#registrations) {
|
||||||
|
this.#enqueue(instanceId, registration.generation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.#stopPromise) return this.#stopPromise;
|
||||||
|
this.#stopping = true;
|
||||||
|
this.#started = false;
|
||||||
|
for (const registration of this.#registrations.values()) {
|
||||||
|
if (registration.timer !== undefined) clearTimeout(registration.timer);
|
||||||
|
registration.timer = undefined;
|
||||||
|
}
|
||||||
|
this.#queue.length = 0;
|
||||||
|
this.#queuedGeneration.clear();
|
||||||
|
for (const run of this.#active.values()) run.controller.abort();
|
||||||
|
|
||||||
|
const settling = [...this.#active.values()].map(({ promise }) => promise);
|
||||||
|
this.#stopPromise = Promise.allSettled(settling).then(() => undefined);
|
||||||
|
return this.#stopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#enqueue(instanceId: string, generation: number): void {
|
||||||
|
if (!this.#started || this.#stopping) return;
|
||||||
|
const registration = this.#registrations.get(instanceId);
|
||||||
|
if (!registration || registration.generation !== generation) return;
|
||||||
|
|
||||||
|
const active = this.#active.get(instanceId);
|
||||||
|
if (active) {
|
||||||
|
active.rerunGeneration = generation;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.#queuedGeneration.get(instanceId) === generation) return;
|
||||||
|
|
||||||
|
this.#cancelQueued(instanceId);
|
||||||
|
this.#queue.push({ instanceId, generation });
|
||||||
|
this.#queuedGeneration.set(instanceId, generation);
|
||||||
|
this.#drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
#drain(): void {
|
||||||
|
while (!this.#stopping && this.#active.size < this.#concurrency && this.#queue.length > 0) {
|
||||||
|
const item = this.#queue.shift()!;
|
||||||
|
if (this.#queuedGeneration.get(item.instanceId) === item.generation) {
|
||||||
|
this.#queuedGeneration.delete(item.instanceId);
|
||||||
|
}
|
||||||
|
const registration = this.#registrations.get(item.instanceId);
|
||||||
|
if (!registration || registration.generation !== item.generation) continue;
|
||||||
|
if (this.#active.has(item.instanceId)) {
|
||||||
|
this.#active.get(item.instanceId)!.rerunGeneration = item.generation;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.#run(item, registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#run(item: QueueItem, registration: Registration): void {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const active: ActiveRun = {
|
||||||
|
generation: item.generation,
|
||||||
|
controller,
|
||||||
|
promise: undefined as unknown as Promise<void>,
|
||||||
|
};
|
||||||
|
this.#active.set(item.instanceId, active);
|
||||||
|
this.#scheduleTick(item.instanceId, registration);
|
||||||
|
|
||||||
|
let result: Promise<void>;
|
||||||
|
try {
|
||||||
|
result = this.#task(item.instanceId, controller.signal);
|
||||||
|
} catch (error) {
|
||||||
|
result = Promise.reject(error);
|
||||||
|
}
|
||||||
|
const promise = Promise.resolve(result).then(
|
||||||
|
() => this.#finish(item.instanceId, active),
|
||||||
|
() => this.#finish(item.instanceId, active),
|
||||||
|
);
|
||||||
|
active.promise = promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scheduleTick(instanceId: string, registration: Registration): void {
|
||||||
|
if (registration.timer !== undefined) return;
|
||||||
|
registration.timer = setTimeout(() => {
|
||||||
|
registration.timer = undefined;
|
||||||
|
if (this.#registrations.get(instanceId) !== registration || this.#stopping) return;
|
||||||
|
this.#scheduleTick(instanceId, registration);
|
||||||
|
this.#enqueue(instanceId, registration.generation);
|
||||||
|
}, registration.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#finish(instanceId: string, completed: ActiveRun): void {
|
||||||
|
if (this.#active.get(instanceId) !== completed) return;
|
||||||
|
this.#active.delete(instanceId);
|
||||||
|
if (!this.#stopping) {
|
||||||
|
const current = this.#registrations.get(instanceId);
|
||||||
|
const rerun = completed.rerunGeneration;
|
||||||
|
if (current && rerun === current.generation) this.#enqueue(instanceId, rerun);
|
||||||
|
this.#drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#cancelQueued(instanceId: string): void {
|
||||||
|
this.#queuedGeneration.delete(instanceId);
|
||||||
|
for (let index = this.#queue.length - 1; index >= 0; index--) {
|
||||||
|
if (this.#queue[index]!.instanceId === instanceId) this.#queue.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#validId(instanceId: string): void {
|
||||||
|
if (instanceId.length === 0) throw new TypeError('instanceId must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
#validInterval(intervalMs: number): number {
|
||||||
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||||
|
throw new RangeError('intervalMs must be greater than zero');
|
||||||
|
}
|
||||||
|
return intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ it('registers the read-only operations catalog without touching upstream', async
|
|||||||
upstreamCalls += 1;
|
upstreamCalls += 1;
|
||||||
throw new Error('unexpected');
|
throw new Error('unexpected');
|
||||||
},
|
},
|
||||||
|
postNetworkRegisterAuto: async () => {
|
||||||
|
upstreamCalls += 1;
|
||||||
|
throw new Error('unexpected');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const response = await app.inject('/api/v1/operations?pageSize=1');
|
const response = await app.inject('/api/v1/operations?pageSize=1');
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ describe('buildControlPlaneApp', () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
request: async () => ({ status: 200, headers: {}, body: '' }),
|
request: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const created = await app.inject({
|
const created = await app.inject({
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { registerOperationRoutes } from './interface/http/operation-routes.js';
|
import { registerOperationRoutes } from './interface/http/operation-routes.js';
|
||||||
import { operationCatalogRegistry } from './application/operations/operation-catalog-data.js';
|
import { operationCatalogRegistry } from './application/operations/operation-catalog-data.js';
|
||||||
|
import {
|
||||||
|
SecureOperationExecution,
|
||||||
|
secureOperationRegistry,
|
||||||
|
} from './application/operations/secure-operation-execution.js';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { buildApp, type BuildAppOptions } from './app.js';
|
import { buildApp, type BuildAppOptions } from './app.js';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +25,7 @@ import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
|||||||
|
|
||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
|
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
||||||
}
|
}
|
||||||
export interface ControlPlaneOptions {
|
export interface ControlPlaneOptions {
|
||||||
readonly db: Database.Database;
|
readonly db: Database.Database;
|
||||||
@@ -54,11 +59,29 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
|
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
|
||||||
: new DeleteInstanceOperation({ db: options.db, instances });
|
: new DeleteInstanceOperation({ db: options.db, instances });
|
||||||
deletion.reconcileInterruptedJobs();
|
deletion.reconcileInterruptedJobs();
|
||||||
|
const secureExecution = new SecureOperationExecution({
|
||||||
|
db: options.db,
|
||||||
|
registry: secureOperationRegistry,
|
||||||
|
transport: {
|
||||||
|
request: async ({ origin }) => {
|
||||||
|
const response = await options.upstream.postNetworkRegisterAuto(origin);
|
||||||
|
return { status: response.status };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(options.now ? { now: options.now } : {}),
|
||||||
|
});
|
||||||
|
secureExecution.reconcileInterruptedJobs();
|
||||||
const app = buildApp({
|
const app = buildApp({
|
||||||
...options.app,
|
...options.app,
|
||||||
registerRoutes: (app) => {
|
registerRoutes: (app) => {
|
||||||
registerInstanceRoutes(app, { instances, connections, login, deletion });
|
registerInstanceRoutes(app, {
|
||||||
registerOperationRoutes(app, operationCatalogRegistry);
|
instances,
|
||||||
|
connections,
|
||||||
|
login,
|
||||||
|
deletion,
|
||||||
|
registerDeletionPreparationRoute: false,
|
||||||
|
});
|
||||||
|
registerOperationRoutes(app, operationCatalogRegistry, secureExecution, deletion);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.assign(app, {
|
Object.assign(app, {
|
||||||
|
|||||||
@@ -228,6 +228,48 @@ export const MIGRATIONS: readonly Migration[] = [
|
|||||||
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'generic-secure-operation-preparation-binding',
|
||||||
|
statements: [
|
||||||
|
`CREATE TABLE operation_preparations_v5 (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
risk_level TEXT NOT NULL CHECK (risk_level IN ('R2','R3')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
||||||
|
target_instance_id TEXT NOT NULL,
|
||||||
|
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
||||||
|
target_origin TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
canonical_query TEXT NOT NULL,
|
||||||
|
body_digest TEXT NOT NULL CHECK (length(body_digest) = 64),
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
parameter_schema_id TEXT NOT NULL,
|
||||||
|
parameters_digest TEXT NOT NULL CHECK (length(parameters_digest) = 64),
|
||||||
|
nonce TEXT NOT NULL UNIQUE,
|
||||||
|
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
||||||
|
requested_by TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
consumed_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
`INSERT INTO operation_preparations_v5
|
||||||
|
(id,operation_id,risk_level,status,target_instance_id,target_revision,target_origin,method,path,
|
||||||
|
canonical_query,body_digest,content_type,parameter_schema_id,parameters_digest,nonce,token_digest,
|
||||||
|
requested_by,request_id,expires_at,consumed_at,created_at,updated_at)
|
||||||
|
SELECT p.id,p.operation_id,p.risk_level,p.status,p.target_instance_id,p.target_revision,
|
||||||
|
COALESCE(i.base_url,''),'DELETE','/api/v1/instances/' || p.target_instance_id,'',
|
||||||
|
'${createHash('sha256').update('').digest('hex')}','',p.parameter_schema_id,p.parameters_digest,
|
||||||
|
'legacy-' || p.id,p.token_digest,p.requested_by,p.request_id,p.expires_at,p.consumed_at,p.created_at,p.updated_at
|
||||||
|
FROM operation_preparations p LEFT JOIN instances i ON i.id=p.target_instance_id`,
|
||||||
|
'DROP TABLE operation_preparations',
|
||||||
|
'ALTER TABLE operation_preparations_v5 RENAME TO operation_preparations',
|
||||||
|
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
|
import { createSafeControlPlaneUpstream } from './safe-control-plane-upstream.js';
|
||||||
|
|
||||||
describe('createSafeControlPlaneUpstream', () => {
|
describe('createSafeControlPlaneUpstream', () => {
|
||||||
it('routes both health GET and login POST through the same safe transport', async () => {
|
it('routes health, login, and the audited R2 operation through the same safe transport', async () => {
|
||||||
const calls: unknown[] = [];
|
const calls: unknown[] = [];
|
||||||
const upstream = createSafeControlPlaneUpstream({
|
const upstream = createSafeControlPlaneUpstream({
|
||||||
get: async (url) => {
|
get: async (url) => {
|
||||||
@@ -22,6 +22,7 @@ describe('createSafeControlPlaneUpstream', () => {
|
|||||||
secret: '[REDACTED]',
|
secret: '[REDACTED]',
|
||||||
body: '[REDACTED]',
|
body: '[REDACTED]',
|
||||||
});
|
});
|
||||||
|
await upstream.postNetworkRegisterAuto('http://192.168.1.20');
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
{ method: 'GET', url: 'http://192.168.1.20/api/health' },
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,12 @@ describe('createSafeControlPlaneUpstream', () => {
|
|||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: '{"password":"[REDACTED]"}',
|
body: '{"password":"[REDACTED]"}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
url: 'http://192.168.1.20/api/network/register-auto',
|
||||||
|
headers: {},
|
||||||
|
body: '',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
|
|||||||
|
|
||||||
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
export interface SafeControlPlaneUpstream extends ConnectionTransport {
|
||||||
request: UpstreamSessionClientOptions['request'];
|
request: UpstreamSessionClientOptions['request'];
|
||||||
|
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
|
||||||
}
|
}
|
||||||
export function createSafeControlPlaneUpstream(
|
export function createSafeControlPlaneUpstream(
|
||||||
transport: SafeUpstreamTransport,
|
transport: SafeUpstreamTransport,
|
||||||
@@ -12,5 +13,6 @@ export function createSafeControlPlaneUpstream(
|
|||||||
return {
|
return {
|
||||||
get: (url) => transport.get(url),
|
get: (url) => transport.get(url),
|
||||||
request: (request) => gateway.request(request),
|
request: (request) => gateway.request(request),
|
||||||
|
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,35 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
|
import { SafeUpstreamGateway } from './safe-upstream-gateway.js';
|
||||||
|
|
||||||
describe('SafeUpstreamGateway', () => {
|
describe('SafeUpstreamGateway', () => {
|
||||||
|
it('dispatches only the audited zero-body network registration operation through pinned POST', async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const gateway = new SafeUpstreamGateway({
|
||||||
|
transport: {
|
||||||
|
get: async () => ({ status: 200, headers: {}, body: '' }),
|
||||||
|
post: async (url, headers, body) => {
|
||||||
|
calls.push({ url, headers, body });
|
||||||
|
return { status: 204, headers: {}, body: '' };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await gateway.postNetworkRegisterAuto('http://192.168.1.20:8080');
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ url: 'http://192.168.1.20:8080/api/network/register-auto', headers: {}, body: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed operation origins before calling transport', async () => {
|
||||||
|
const post = vi.fn(async () => ({ status: 204, headers: {}, body: '' }));
|
||||||
|
const gateway = new SafeUpstreamGateway({
|
||||||
|
transport: { get: async () => ({ status: 200, headers: {}, body: '' }), post },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
gateway.postNetworkRegisterAuto('http://192.168.1.20/base?next=x'),
|
||||||
|
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it('sends login password only as JSON through the pinned POST transport', async () => {
|
it('sends login password only as JSON through the pinned POST transport', async () => {
|
||||||
const calls: unknown[] = [];
|
const calls: unknown[] = [];
|
||||||
const gateway = new SafeUpstreamGateway({
|
const gateway = new SafeUpstreamGateway({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from '../../application/connections/upstream-session-client.js';
|
} from '../../application/connections/upstream-session-client.js';
|
||||||
import type { TransportResponse } from './safe-instance-transport.js';
|
import type { TransportResponse } from './safe-instance-transport.js';
|
||||||
import { UpstreamError } from './upstream-error.js';
|
import { UpstreamError } from './upstream-error.js';
|
||||||
|
import { OperationNotDispatchedError } from '../../application/operations/secure-operation-execution.js';
|
||||||
|
|
||||||
export interface SafeUpstreamTransport {
|
export interface SafeUpstreamTransport {
|
||||||
get(url: string): Promise<TransportResponse>;
|
get(url: string): Promise<TransportResponse>;
|
||||||
@@ -15,6 +16,34 @@ export interface SafeUpstreamTransport {
|
|||||||
}
|
}
|
||||||
export class SafeUpstreamGateway {
|
export class SafeUpstreamGateway {
|
||||||
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
|
||||||
|
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(origin);
|
||||||
|
} catch {
|
||||||
|
throw this.notDispatched();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||||
|
parsed.username ||
|
||||||
|
parsed.password ||
|
||||||
|
parsed.pathname !== '/' ||
|
||||||
|
parsed.search ||
|
||||||
|
parsed.hash
|
||||||
|
)
|
||||||
|
throw this.notDispatched();
|
||||||
|
const url = `${parsed.origin}/api/network/register-auto`;
|
||||||
|
try {
|
||||||
|
return await this.options.transport.post(url, {}, '');
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof UpstreamError &&
|
||||||
|
(error.code === 'UNSAFE_ORIGIN' || error.code === 'UNSAFE_RESOLUTION')
|
||||||
|
)
|
||||||
|
throw new OperationNotDispatchedError(error.code);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
async request(request: UpstreamRequest): Promise<UpstreamResponse> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
|
||||||
@@ -35,4 +64,7 @@ export class SafeUpstreamGateway {
|
|||||||
}
|
}
|
||||||
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
private notDispatched(): OperationNotDispatchedError {
|
||||||
|
return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface InstanceRoutesOptions {
|
|||||||
readonly connections?: ConnectionProbe;
|
readonly connections?: ConnectionProbe;
|
||||||
readonly login?: InstanceLoginService;
|
readonly login?: InstanceLoginService;
|
||||||
readonly deletion?: DeleteInstanceOperation;
|
readonly deletion?: DeleteInstanceOperation;
|
||||||
|
readonly registerDeletionPreparationRoute?: boolean;
|
||||||
}
|
}
|
||||||
const problem = (
|
const problem = (
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
@@ -379,10 +380,12 @@ export function registerInstanceRoutes(app: FastifyInstance, options: InstanceRo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.deletion) {
|
if (options.deletion) {
|
||||||
|
if (options.registerDeletionPreparationRoute !== false) {
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/operations/prepare',
|
'/api/v1/operations/prepare',
|
||||||
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
wrap(async (request) => options.deletion!.prepare(request.body as never, request.id)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
app.delete('/api/v1/instances/:instanceId', async (request, reply) => {
|
||||||
const handled = await wrap(async (wrappedRequest) => {
|
const handled = await wrap(async (wrappedRequest) => {
|
||||||
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
const preparationId = wrappedRequest.headers['x-preparation-id'];
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import type { OperationPageQuery } from '@multi-simadmin/contracts';
|
import type {
|
||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
ExecuteOperationRequest,
|
||||||
|
OperationPageQuery,
|
||||||
|
PrepareOperationRequest,
|
||||||
|
} from '@multi-simadmin/contracts';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
|
import { OperationCatalogService } from '../../application/operations/operation-catalog-service.js';
|
||||||
import type { OperationCatalogRegistry } 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 capabilities = new Set(['query', 'command', 'job']);
|
||||||
const keys = new Set([
|
const keys = new Set([
|
||||||
@@ -89,6 +101,8 @@ function parseQuery(request: FastifyRequest): OperationPageQuery {
|
|||||||
export function registerOperationRoutes(
|
export function registerOperationRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
registry: OperationCatalogRegistry,
|
registry: OperationCatalogRegistry,
|
||||||
|
execution?: SecureOperationExecution,
|
||||||
|
deletion?: DeleteInstanceOperation,
|
||||||
): void {
|
): void {
|
||||||
const service = new OperationCatalogService(registry);
|
const service = new OperationCatalogService(registry);
|
||||||
app.get('/api/v1/operations', async (request, reply) => {
|
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