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