feat(api): complete phase 2.4 control plane
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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,
|
||||
DeleteInstanceOperation,
|
||||
DeleteInstanceOperationError,
|
||||
} from './delete-instance-operation.js';
|
||||
|
||||
class Store implements SecretStore {
|
||||
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() {
|
||||
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,
|
||||
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('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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user