import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, readFile, lstat, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import * as org from '../src/org-service.mjs'; import { listOrgs } from '../src/org-registry.mjs'; import { busStopForDelete as busStop } from '../../cli/src/org-bus.mjs'; async function fixture() { const root = await mkdtemp(join(tmpdir(), 'org-delete-')); const c = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; await mkdir(c.configDir, { mode: 0o700 }); await mkdir(c.dataRoot, { mode: 0o700 }); await org.createOrg(c, 'alpha', await org.templateRender('alpha')); return c; } test('delete refuses without a bus stop function', async () => { const c = await fixture(); await assert.rejects(org.deleteOrg(c, 'alpha', { confirm: 'alpha', ifRevision: 1 }), { code: 'org-invalid' }); assert.equal((await listOrgs(c)).find(e => e.id === 'alpha').state, 'active'); }); test('delete fences confirmation and revision before moving any files', async () => { const c = await fixture(); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'other', ifRevision: 1 }), { code: 'org-confirm' }); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 2 }), { code: 'revision-conflict' }); assert.equal((await org.showOrg(c, 'alpha')).revision, 1); }); test('delete preserves owned trees, records a receipt, and permits reuse', async () => { const c = await fixture(); for (const kind of ['agents', 'grants', 'hosts']) { const dir = join(c.configDir, 'secrets', kind, 'alpha'); await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, 'dummy'), 'secret', { mode: 0o600 }); } const history = join(c.configDir, 'businesses', '.history', 'alpha'); await mkdir(history, { recursive: true, mode: 0o700 }); await writeFile(join(history, 'changes.jsonl'), 'evidence', { mode: 0o600 }); const receipt = await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }); assert.equal(await readFile(join(receipt.destination, 'history', 'changes.jsonl'), 'utf8'), 'evidence'); assert.equal((await lstat(receipt.destination)).mode & 0o777, 0o700); assert.equal(receipt.secrets.length, 3); assert.ok(receipt.secrets.every(s => s.entryCount === 1)); assert.equal((await listOrgs(c)).length, 0); const stored = JSON.parse(await readFile(join(receipt.destination, 'receipt.json'), 'utf8')); assert.equal(stored.registry, 'removed'); assert.equal(stored.org, 'alpha'); assert.ok(!JSON.stringify(stored).includes('dummy')); assert.equal((await readFile(join(c.dataRoot, 'deleted', 'deleted.log'), 'utf8')).trim().split('\n').length, 1); await org.createOrg(c, 'alpha', await org.templateRender('alpha')); }); test('delete refuses an owned symlink without moving anything', async () => { const c = await fixture(); await symlink('/tmp', join(c.dataRoot, 'org-runtime', 'alpha', 'link')); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), { code: 'org-mismatch' }); assert.equal((await listOrgs(c))[0].state, 'deleting'); }); test('delete refuses a group-readable organization secret directory', async () => { const c = await fixture(); const dir = join(c.configDir, 'secrets', 'agents', 'alpha'); await mkdir(dir, { recursive: true, mode: 0o700 }); const { chmod } = await import('node:fs/promises'); await chmod(dir, 0o750); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), { code: 'org-mismatch' }); assert.equal((await listOrgs(c))[0].state, 'deleting'); }); test('delete refuses a symlink within an organization secret directory', async () => { const c = await fixture(); const dir = join(c.configDir, 'secrets', 'hosts', 'alpha'); await mkdir(dir, { recursive: true, mode: 0o700 }); await symlink('/tmp', join(dir, 'link')); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), { code: 'org-mismatch' }); assert.equal((await listOrgs(c))[0].state, 'deleting'); }); test('a refused deletion resumes without a revision after the unsafe path is fixed', async () => { const c = await fixture(); const link = join(c.dataRoot, 'org-runtime', 'alpha', 'link'); await symlink(c.dataRoot, link); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 })); const { unlink } = await import('node:fs/promises'); await unlink(link); await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha' }); assert.deepEqual(await listOrgs(c), []); }); test('deleting state fences bus start, and an uncertain bus stop retains the organization', async () => { const c = await fixture(); const hostDir = join(c.dataRoot, 'org-runtime', 'alpha', 'bus-host'); await mkdir(hostDir, { mode: 0o700 }); await writeFile(join(hostDir, 'host.lock'), JSON.stringify({ pid: 99999999, startTime: '0', business: 'alpha' }), { mode: 0o600 }); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), /lock/i); assert.equal((await listOrgs(c))[0].state, 'deleting'); const { busStart } = await import('../../cli/src/org-bus.mjs'); await assert.rejects(busStart(c, 'alpha'), /being deleted/); const { unlink } = await import('node:fs/promises'); await unlink(join(hostDir, 'host.lock')); await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha' }); }); test('deletion refuses to move files while an in-flight bus start holds its lifecycle fence', async () => { const c = await fixture(); const lock = join(c.configDir, 'org-lifecycle-alpha.lock'); await writeFile(lock, 'in flight', { mode: 0o600 }); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), /lifecycle/i); assert.equal((await listOrgs(c))[0].state, 'active'); await lstat(join(c.dataRoot, 'org-runtime', 'alpha')); const { unlink } = await import('node:fs/promises'); await unlink(lock); await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }); }); test('resume refuses a replaced data root symlink', async () => { const c = await fixture(), link = join(c.dataRoot, 'org-runtime', 'alpha', 'link'); await symlink(c.configDir, link); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 })); const { rename, unlink } = await import('node:fs/promises'); await unlink(link); await rename(c.dataRoot, c.dataRoot + '-real'); await symlink(c.dataRoot + '-real', c.dataRoot); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha' }), { code: 'org-mismatch' }); }); test('bus verification refuses a symlinked host state before reading it', async () => { const c = await fixture(), host = join(c.dataRoot, 'org-runtime', 'alpha', 'bus-host'); await mkdir(host, { mode: 0o700 }); await symlink(join(c.configDir, 'missing'), join(host, 'host.json')); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), /symlink/i); assert.equal((await listOrgs(c))[0].state, 'deleting'); }); test('bus verification refuses a non-file host state', async () => { const c = await fixture(), host = join(c.dataRoot, 'org-runtime', 'alpha', 'bus-host'); await mkdir(host, { mode: 0o700 }); await mkdir(join(host, 'host.json'), { mode: 0o700 }); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), /unsafe bus state/i); }); test('delete refuses an answering bus socket even without a recorded host', async t => { const c = await fixture(), dir = join(c.dataRoot, 'org-runtime', 'alpha', 'bus'); await mkdir(dir, { mode: 0o700 }); const { createServer } = await import('node:net'); const server = createServer(socket => socket.end()); await new Promise((resolve, reject) => server.once('error', reject).listen(join(dir, 'broker.sock'), resolve)); t.after(() => new Promise(resolve => server.close(resolve))); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), /socket still answers/i); assert.equal((await listOrgs(c))[0].state, 'deleting'); }); test('a refused move reports the completed bus step and the point of refusal', async () => { const c = await fixture(); await symlink(c.configDir, join(c.dataRoot, 'org-runtime', 'alpha', 'link')); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }), e => { assert.equal(e.deleteStep, 'inspect'); assert.equal(e.deleteReceipt.bus.reason, 'no host runs'); assert.deepEqual(e.deleteReceipt.moves, []); return true; }); }); test('resume records a move completed before its receipt could be updated', async () => { const c = await fixture(), root = join(c.dataRoot, 'org-runtime', 'alpha'), link = join(root, 'link'); await symlink(c.configDir, link); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 })); const entry = (await listOrgs(c))[0]; const { unlink, rename } = await import('node:fs/promises'); await unlink(link); await rename(root, join(c.dataRoot, 'deleted', entry.deletion.folder, 'runtime')); const receipt = await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha' }); assert.ok(receipt.moves.some(move => move.from === root)); assert.equal(receipt.registry, 'removed'); }); test('deleting another organization appends without rewriting prior log entries', async () => { const c = await fixture(); await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }); const file = join(c.dataRoot, 'deleted', 'deleted.log'), before = await readFile(file, 'utf8'); await org.createOrg(c, 'beta', await org.templateRender('beta')); await org.deleteOrg(c, 'beta', { busStop, confirm: 'beta', ifRevision: 1 }); const after = await readFile(file, 'utf8'); assert.ok(after.startsWith(before)); assert.equal(after.trim().split('\n').length, 2); }); test('resume refuses an instance rebound to a different data root', async () => { const c = await fixture(), link = join(c.dataRoot, 'org-runtime', 'alpha', 'link'); await symlink(c.configDir, link); await assert.rejects(org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 })); const other = c.dataRoot + '-other'; await mkdir(other, { mode: 0o700 }); await assert.rejects(org.deleteOrg({ ...c, dataRoot: other }, 'alpha', { busStop, confirm: 'alpha' }), { code: 'org-mismatch' }); await assert.rejects(lstat(join(other, 'deleted')), { code: 'ENOENT' }); }); // Hold the first request immediately before its bus receipt is published. test('overlapping deletes refuse before touching the receipt', async () => { const c = await fixture(); const fs = (await import('node:fs/promises')).default; const { syncBuiltinESMExports } = await import('node:module'); let entered, release; const paused = new Promise(resolve => { entered = resolve; }); const proceed = new Promise(resolve => { release = resolve; }); const original = fs.rename; let pausedOnce = false; fs.rename = async (from, to) => { if (!pausedOnce && to.endsWith('/receipt.json')) { const value = JSON.parse(await readFile(from, 'utf8')); if (value.bus && value.moves.length === 0) { pausedOnce = true; entered(); await proceed; } } return original(from, to); }; syncBuiltinESMExports(); let first; try { first = org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }); await paused; const second = await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 }).then(value => ({ value }), error => ({ error })); assert.equal(second.error?.code, 'org-locked'); } finally { release(); try { await first; } catch { /* preserve the assertion failure */ } fs.rename = original; syncBuiltinESMExports(); } const result = await first; const stored = JSON.parse(await readFile(join(result.destination, 'receipt.json'), 'utf8')); assert.equal(stored.registry, 'removed'); assert.equal(stored.moves.length, 2); });