/** * Deployed-stack deletion: ready transaction retires both recovery models; * blocking intents gate same-name create. */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'crypto'; import path from 'path'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import type { StackUpdateRecoveryGenerationRow, ServiceUpdateRecoveryRow, StackUpdateCleanupPendingRow, } from '../services/DatabaseService'; let tmpDir: string; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let DeployedStackDeletionService: typeof import('../services/DeployedStackDeletionService').DeployedStackDeletionService; let overrideDeletionContainmentBase: typeof import('../services/DeployedStackDeletionService').overrideDeletionContainmentBase; beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); ({ DeployedStackDeletionService, overrideDeletionContainmentBase } = await import('../services/DeployedStackDeletionService')); }); afterAll(() => cleanupTestDb(tmpDir)); function db() { return DatabaseService.getInstance(); } beforeEach(() => { const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; raw.prepare('DELETE FROM stack_update_recovery_generations').run(); raw.prepare('DELETE FROM service_update_recovery').run(); raw.prepare('DELETE FROM stack_update_cleanup_pending').run(); }); const NODE = 1; describe('DeployedStackDeletionService ready transaction', () => { it('commitStackDeletionReadyTransaction deletes full-stack and service recovery rows', () => { const now = Date.now(); const stackName = 'del-stack'; const gen: StackUpdateRecoveryGenerationRow = { id: randomUUID(), node_id: NODE, stack_name: stackName, status: 'active', phase: 'immediate_verified', is_current: 1, backup_slot_id: null, override_path: null, services_json: '[]', health_gate_id: null, gate_retain_until: null, artifact_expires_at: null, operation_lease_expires_at: null, created_at: now, updated_at: now, created_by: null, artifacts_retired: 0, }; db().insertStackUpdateRecoveryGeneration(gen); const svc: ServiceUpdateRecoveryRow = { id: randomUUID(), node_id: NODE, stack_name: stackName, service_name: 'web', replicas_json: '[]', majority_image_id: 'sha256:abc', declared_image_ref: 'nginx:latest', weak_floating_tag: 0, health_gate_id: null, status: 'active', expires_at: now + 60_000, claim_expires_at: null, created_at: now, created_by: null, }; db().insertServiceUpdateRecovery(svc); const intentId = randomUUID(); const intent: StackUpdateCleanupPendingRow = { id: intentId, node_id: NODE, stack_name: stackName, status: 'prepared', target_kind: 'local_socket', rollback_tags_json: '[]', override_paths_json: '[]', prune_volumes_requested: 0, created_at: now, updated_at: now, }; db().insertCleanupPending(intent); expect(db().commitStackDeletionReadyTransaction(intentId, NODE, stackName)).toBe(true); expect(db().listStackUpdateRecoveryForStack(NODE, stackName)).toHaveLength(0); expect(db().listActiveServiceUpdateRecoveries(NODE, stackName, 'web')).toHaveLength(0); expect(db().getCleanupPending(intentId)?.status).toBe('ready'); }); it('hasBlockingDeletionIntent is true for prepared intents', () => { const now = Date.now(); db().insertCleanupPending({ id: randomUUID(), node_id: NODE, stack_name: 'blocked', status: 'prepared', target_kind: 'local_socket', rollback_tags_json: '[]', override_paths_json: '[]', prune_volumes_requested: 0, created_at: now, updated_at: now, }); expect(db().hasBlockingDeletionIntent(NODE, 'blocked')).toBe(true); expect(db().hasBlockingDeletionIntent(NODE, 'other')).toBe(false); }); it('assertNoBlockingDeletionIntent throws for prepared stacks', () => { const now = Date.now(); db().insertCleanupPending({ id: randomUUID(), node_id: NODE, stack_name: 'prep', status: 'prepared', target_kind: 'local_socket', rollback_tags_json: '[]', override_paths_json: '[]', prune_volumes_requested: 0, created_at: now, updated_at: now, }); expect(() => { DeployedStackDeletionService.getInstance().assertNoBlockingDeletionIntent(NODE, 'prep'); }).toThrow(/deletion in progress/i); }); }); describe('overrideDeletionContainmentBase', () => { it('confines stack-scoped intents to the stack directory', () => { expect(overrideDeletionContainmentBase('/app/compose', 'my-stack')).toBe( path.resolve('/app/compose', 'my-stack'), ); expect(overrideDeletionContainmentBase('/app/compose', null)).toBe( path.resolve('/app/compose'), ); }); it('rejects invalid stack names that could traverse', () => { expect(overrideDeletionContainmentBase('/app/compose', '../other')).toBeNull(); expect(overrideDeletionContainmentBase('/app/compose', 'bad/name')).toBeNull(); }); });