Files
multi-simadmin/apps/api/src/application/operations/delete-instance-operation.test.ts
T
chick e4c4bce75b feat(secrets): add a 0600 file secret backend so production runs on Linux
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
2026-09-07 01:32:07 +08:00

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();
});
});