The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend
491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { createHash } from 'node:crypto';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { InstanceService } from '../instances/instance-service.js';
|
|
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
|
import {
|
|
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
|
type BindingRelease,
|
|
DeleteInstanceOperation,
|
|
DeleteInstanceOperationError,
|
|
} from './delete-instance-operation.js';
|
|
|
|
class Store implements SecretStore {
|
|
readonly provider = 'macos-keychain';
|
|
async set() {
|
|
return '';
|
|
}
|
|
async get() {
|
|
return undefined;
|
|
}
|
|
async delete() {
|
|
return false;
|
|
}
|
|
}
|
|
const databases: Database.Database[] = [];
|
|
afterEach(() => {
|
|
for (const database of databases.splice(0)) database.close();
|
|
});
|
|
|
|
function fixture(
|
|
releaseBinding?: (instanceId: string, requestId: string) => Promise<BindingRelease>,
|
|
) {
|
|
const db = new Database(':memory:');
|
|
db.pragma('foreign_keys=ON');
|
|
migrateDatabase(db);
|
|
databases.push(db);
|
|
let sequence = 0;
|
|
const now = () => new Date('2026-07-16T12:00:00.000Z');
|
|
const instances = new InstanceService({
|
|
db,
|
|
store: new Store(),
|
|
idFactory: () => `instance-${++sequence}`,
|
|
now,
|
|
});
|
|
const operation = new DeleteInstanceOperation({
|
|
db,
|
|
instances,
|
|
now,
|
|
...(releaseBinding === undefined ? {} : { releaseBinding }),
|
|
idFactory: () => `operation-${++sequence}`,
|
|
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
|
|
});
|
|
return { db, instances, operation };
|
|
}
|
|
|
|
describe('DeleteInstanceOperation', () => {
|
|
it('prepares only the fixed delete shape and persists only a token digest with TTL', 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: [] },
|
|
});
|
|
expect(prepared).toMatchObject({
|
|
status: 'prepared',
|
|
operationId: 'deleteInstance',
|
|
risk: 'R3',
|
|
targetCount: 1,
|
|
});
|
|
expect(Buffer.from(prepared.confirmationToken, 'base64url')).toHaveLength(32);
|
|
expect(prepared.expiresAt).toBe('2026-07-16T12:05:00.000Z');
|
|
const row = db
|
|
.prepare('SELECT token_digest, expires_at FROM operation_preparations WHERE id=?')
|
|
.get(prepared.id) as { token_digest: string; expires_at: string };
|
|
expect(row.token_digest).toBe(
|
|
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
|
|
);
|
|
expect(JSON.stringify(db.prepare('SELECT * FROM operation_preparations').all())).not.toContain(
|
|
prepared.confirmationToken,
|
|
);
|
|
|
|
for (const input of [
|
|
{
|
|
operationId: 'other',
|
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
|
},
|
|
{
|
|
operationId: 'deleteInstance',
|
|
targets: [],
|
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
|
},
|
|
{
|
|
operationId: 'deleteInstance',
|
|
targets: [{ instanceId: instance.id, revision: 0 }],
|
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
|
},
|
|
{
|
|
operationId: 'deleteInstance',
|
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
|
parameters: { parameterSchemaId: 'wrong', fields: [] },
|
|
},
|
|
{
|
|
operationId: 'deleteInstance',
|
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
|
parameters: {
|
|
parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
|
fields: [{ fieldId: 'x', kind: 'string' as const, value: 'x' }],
|
|
},
|
|
},
|
|
{
|
|
operationId: 'deleteInstance',
|
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
|
extra: true,
|
|
},
|
|
null,
|
|
])
|
|
await expect(operation.prepare(input as never)).rejects.toBeInstanceOf(
|
|
DeleteInstanceOperationError,
|
|
);
|
|
});
|
|
|
|
it('consumes once, creates immutable history, deletes safely, and returns a frozen Job shape', 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: [] },
|
|
});
|
|
const job = await operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-1',
|
|
});
|
|
expect(job).toEqual({
|
|
id: expect.any(String),
|
|
operationId: 'deleteInstance',
|
|
status: 'succeeded',
|
|
rootJobId: expect.any(String),
|
|
items: [{ id: expect.any(String), targetId: instance.id, state: 'succeeded' }],
|
|
attempts: [
|
|
{
|
|
id: expect.any(String),
|
|
state: 'succeeded',
|
|
startedAt: expect.any(String),
|
|
finishedAt: expect.any(String),
|
|
},
|
|
],
|
|
createdAt: expect.any(String),
|
|
});
|
|
expect(job.rootJobId).toBe(job.id);
|
|
expect(await instances.get(instance.id)).toBeUndefined();
|
|
expect(db.prepare('SELECT instance_id FROM job_items').get()).toEqual({
|
|
instance_id: instance.id,
|
|
});
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-2',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 1 });
|
|
});
|
|
|
|
it('does not consume a preparation when the token is wrong', 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: [] },
|
|
});
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: 'x'.repeat(20),
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-1',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
|
expect(
|
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
|
).toEqual({ status: 'prepared' });
|
|
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 () => {
|
|
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: [] },
|
|
});
|
|
await instances.update(instance.id, 1, { name: 'B' });
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'stale',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'REVISION_CONFLICT' });
|
|
expect(
|
|
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
|
|
).toEqual({ status: 'consumed' });
|
|
|
|
const current = await instances.get(instance.id);
|
|
const missing = await operation.prepare({
|
|
operationId: 'deleteInstance',
|
|
targets: [{ instanceId: instance.id, revision: current!.revision }],
|
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
|
});
|
|
await instances.delete(instance.id, current!.revision);
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: current!.revision,
|
|
preparationId: missing.id,
|
|
confirmationToken: missing.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'missing',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'NOT_FOUND' });
|
|
});
|
|
|
|
it('reconciles interrupted running delete jobs to unknown-result', async () => {
|
|
const { db, instances, operation } = fixture();
|
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
|
const now = '2026-07-16T12:00:00.000Z';
|
|
db.prepare(
|
|
"INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at) VALUES ('j','j','deleteInstance','R3','running','a','r','d',?,?,?)",
|
|
).run(now, now, now);
|
|
db.prepare(
|
|
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES ('a','j','running',?,?)",
|
|
).run(now, now);
|
|
db.prepare(
|
|
"INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES ('i','j',?,1,'running',?,?,?)",
|
|
).run(instance.id, now, now, now);
|
|
expect(operation.reconcileInterruptedJobs()).toBe(1);
|
|
expect(db.prepare("SELECT status FROM jobs WHERE id='j'").get()).toEqual({
|
|
status: 'unknown-result',
|
|
});
|
|
expect(db.prepare("SELECT status FROM job_items WHERE id='i'").get()).toEqual({
|
|
status: 'unknown-result',
|
|
});
|
|
expect(db.prepare("SELECT status FROM job_attempts WHERE id='a'").get()).toEqual({
|
|
status: 'unknown-result',
|
|
});
|
|
});
|
|
|
|
it('does not delete when durable Job history cannot be created at execution entry', 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.exec(
|
|
"CREATE TRIGGER reject_delete_job_item BEFORE INSERT ON job_items BEGIN SELECT RAISE(ABORT, 'blocked'); END",
|
|
);
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-1',
|
|
}),
|
|
).rejects.toThrow();
|
|
expect(await instances.get(instance.id)).toBeDefined();
|
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
|
});
|
|
|
|
it('rejects an expired preparation once and never creates a Job', async () => {
|
|
const { db, instances } = fixture();
|
|
let current = new Date('2026-07-16T12:00:00.000Z');
|
|
let sequence = 100;
|
|
const operation = new DeleteInstanceOperation({
|
|
db,
|
|
instances,
|
|
now: () => current,
|
|
idFactory: () => `expiry-${++sequence}`,
|
|
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
|
|
});
|
|
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: [] },
|
|
});
|
|
current = new Date('2026-07-16T12:05:00.001Z');
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-expired',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
|
await expect(
|
|
operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-replay',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
|
});
|
|
|
|
it('keeps the node when the device refuses to release its binding', async () => {
|
|
const calls: string[] = [];
|
|
const { instances, operation } = fixture(async (instanceId, requestId) => {
|
|
calls.push(`${instanceId}:${requestId}`);
|
|
return { status: 'failed' };
|
|
});
|
|
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: [] },
|
|
});
|
|
const job = await operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-refused',
|
|
});
|
|
expect(calls).toEqual([`${instance.id}:request-refused`]);
|
|
expect(job.status).toBe('failed');
|
|
expect(job.items).toEqual([
|
|
{
|
|
id: expect.any(String),
|
|
targetId: instance.id,
|
|
state: 'failed',
|
|
error: {
|
|
type: 'about:blank',
|
|
title: 'Job item failed',
|
|
status: 502,
|
|
detail: 'The job item did not complete successfully.',
|
|
code: 'UNBIND_FAILED',
|
|
requestId: 'request-refused',
|
|
},
|
|
},
|
|
]);
|
|
expect(await instances.get(instance.id)).toBeDefined();
|
|
});
|
|
|
|
it('forgets a node that never answered, exactly as an offline device is dropped', async () => {
|
|
const { instances, operation } = fixture(async () => ({ status: 'skipped' }));
|
|
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: [] },
|
|
});
|
|
const job = await operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-offline',
|
|
});
|
|
expect(job.status).toBe('succeeded');
|
|
expect(job.items[0]).not.toHaveProperty('error');
|
|
expect(await instances.get(instance.id)).toBeUndefined();
|
|
});
|
|
|
|
it('treats a transport that throws while the node was online as a refusal', async () => {
|
|
const { db, instances, operation } = fixture(async () => {
|
|
throw new Error('ECONNREFUSED');
|
|
});
|
|
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: [] },
|
|
});
|
|
const job = await operation.executeDelete({
|
|
instanceId: instance.id,
|
|
revision: 1,
|
|
preparationId: prepared.id,
|
|
confirmationToken: prepared.confirmationToken,
|
|
actor: 'loopback-control-plane',
|
|
requestId: 'request-thrown',
|
|
});
|
|
expect(job.items[0]).toMatchObject({ state: 'failed', error: { code: 'UNBIND_FAILED' } });
|
|
expect(db.prepare('SELECT result_code FROM job_items').get()).toEqual({
|
|
result_code: 'UNBIND_FAILED',
|
|
});
|
|
expect(await instances.get(instance.id)).toBeDefined();
|
|
});
|
|
});
|