Fix organization deletion review findings

This commit is contained in:
jason.woltje committed 2026-11-07 22:36:33 -06:00
1 parent 047825460f
commit 614559a94c
8 files changed
+235 -84

No files matched your search

+23
View File
@@ -83,3 +83,26 @@ Verification counts from the clean Node 26 environment:
- deleteOrg no longer imports packages/cli/src/org-bus.mjs. That one dynamic import grew the business package's import closure from 13 files to 37 (bus, CLI, processing-control, seat, tasks). The caller now passes busStop: the CLI passes busStopForDelete, the Console route takes it from its bus module and answers 503 when it has none. deleteOrg refuses without it, before any change.
- Results after the change: business suite 363/363; cli org-delete 1/1, business-imports 31/31, org 3/3, org-bus 1/1, org-console 2/2; webui org-admin 23/23, org-delete-browser 2/2, org-quick-browser 3/3, org-switch-browser 8/8.
- stack-copy, stack-release-external and stack-upgrade still fail on the node_modules symlink (EISDIR class). Known; not run green here.
## Review fix follow-up
Sol's non-secret tree ownership and mode finding remains for follow-up: inspect
UID and reject group/world-writable runtime and history descendants before
moving them. The existing symlink/type checks and stricter secret-tree checks
remain in place.
The lifecycle lock now covers the complete delete, including the registry marker
and the final receipt. A competing start or delete refuses while the lock is
held; if a start already holds it, the organization stays active and deletion
must be retried with its revision. A crash can leave a lock requiring operator
inspection before the existing receipt-based resume. The registry lock is not
held across the bus stop. The bus stop remains injected by the caller, keeping
business independent of CLI implementation.
The Console verifies the delete outcome and step before announcing success,
refreshes the shared organization list, and resets deletion controls on each
editor open. Confirmation spacing uses the shell's pad and gap tokens. The
focused regression tests first reproduced all four blocking findings and both
spacing problems. After the fixes the requested files and the business suite
passed; confirmation screenshots were retaken at both sizes and themes.
+1 -1
View File
@@ -128,7 +128,7 @@ export async function activateOrg(ctx, id) {
});
}
// Serialize bus launch with deletion after its registry marker is published.
// Serialize bus launch with the whole deletion, including its registry marker.
// A leftover lock is never guessed stale; the operator must inspect it.
export async function withOrgLifecycle(ctx, id, fn) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
+74 -72
View File
@@ -248,80 +248,82 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
authorize(ctx); checkId(id);
if (typeof busStop !== 'function') fail('org-invalid', 'deletion needs a bus stop function');
if (confirm !== id) fail('org-confirm', 'confirmation must match the organization ID');
const deletion = await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry) fail('org-unknown', 'unknown organization');
if (entry.root !== orgRoot(ctx.dataRoot, id)) fail('org-mismatch', 'organization binding mismatch');
if (entry.state === 'deleting') {
if (!entry.deletion || !same(entry.deletion.actor, ctx.principal) || !/^[a-zA-Z0-9._-]+$/.test(entry.deletion.folder) || !entry.deletion.folder.startsWith(`${id}-`)) fail('org-mismatch', 'invalid deletion marker');
return entry.deletion;
}
const current = await readCurrentUnlocked(ctx, id);
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
const marker = { actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision, folder: `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}` };
entry.state = 'deleting'; entry.deletion = marker;
return marker;
});
const deleted = join(ctx.dataRoot, 'deleted'), destination = join(deleted, deletion.folder), receiptFile = join(destination, 'receipt.json');
let registryRemoved = false, result, phase = 'prepare';
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = JSON.parse(await privateRead(receiptFile)); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
phase = 'bus';
result.bus = await withOrgLifecycle(ctx, id, () => busStop(ctx, id));
await atomic(receiptFile, encode(result));
}
phase = 'inspect';
await withRegistry(ctx, async registry => {
return withOrgLifecycle(ctx, id, async () => {
const deletion = await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry || entry.state !== 'deleting' || entry.deletion?.folder !== deletion.folder) fail('org-mismatch', 'deletion marker changed');
const owned = [
{ base: ctx.dataRoot, rel: `org-runtime/${id}`, to: 'runtime', required: true },
{ base: ctx.configDir, rel: `businesses/${id}.json`, to: 'organization.json', required: true },
{ base: ctx.configDir, rel: `businesses/.history/${id}`, to: 'history' },
...['agents', 'grants', 'hosts'].map(kind => ({ base: ctx.configDir, rel: `secrets/${kind}/${id}`, to: `secrets-${kind}`, secret: true })),
];
// Inspect all sources and destinations before moving the first one.
for (const item of owned) {
item.from = join(item.base, item.rel); item.dest = join(destination, item.to);
const parents = await deleteParents(item.base, item.rel);
item.count = parents ? await deleteInspect(item.from, { secret: item.secret }) : null;
const prior = result.moves.find(m => m.from === item.from);
const destCount = await deleteInspect(item.dest, { secret: item.secret });
if (prior && (item.count !== null || destCount === null)) fail('org-mismatch', 'moved path does not match receipt');
if (!prior && item.count !== null && destCount !== null) fail('org-mismatch', 'deletion destination already exists');
if (!prior && item.count === null && destCount !== null) item.recovered = true;
if (!prior && item.count === null && destCount === null && item.required) fail('org-mismatch', 'required organization path is missing');
if (item.recovered) item.count = destCount;
if (!entry) fail('org-unknown', 'unknown organization');
if (entry.root !== orgRoot(ctx.dataRoot, id)) fail('org-mismatch', 'organization binding mismatch');
if (entry.state === 'deleting') {
if (!entry.deletion || !same(entry.deletion.actor, ctx.principal) || !/^[a-zA-Z0-9._-]+$/.test(entry.deletion.folder) || !entry.deletion.folder.startsWith(`${id}-`)) fail('org-mismatch', 'invalid deletion marker');
return entry.deletion;
}
phase = 'move';
for (const item of owned) {
if (result.moves.some(m => m.from === item.from) || item.count === null) continue;
if (!item.recovered) await fs.rename(item.from, item.dest);
result.moves.push({ from: item.from, to: item.dest });
if (item.secret) result.secrets.push({ path: item.from, entryCount: item.count });
const current = await readCurrentUnlocked(ctx, id);
if (!Number.isSafeInteger(ifRevision) || current.revision !== ifRevision) fail('revision-conflict', 'organization revision changed');
const marker = { actor: { ...ctx.principal }, time: new Date().toISOString(), revision: current.revision, folder: `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}` };
entry.state = 'deleting'; entry.deletion = marker;
return marker;
});
const deleted = join(ctx.dataRoot, 'deleted'), destination = join(deleted, deletion.folder), receiptFile = join(destination, 'receipt.json');
let registryRemoved = false, result, phase = 'prepare';
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = JSON.parse(await privateRead(receiptFile)); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
phase = 'bus';
result.bus = await busStop(ctx, id);
await atomic(receiptFile, encode(result));
}
phase = 'registry';
const log = join(deleted, 'deleted.log');
const fd = await fs.open(log, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe deletion log');
const lines = (await fd.readFile('utf8')).split('\n');
if (!lines.some(line => { try { return JSON.parse(line).destination === destination; } catch { return false; } })) await fd.writeFile(JSON.stringify({ org: id, destination, time: deletion.time, actor: deletion.actor, revision: deletion.revision }) + '\n');
} finally { await fd.close(); }
result.registry = 'removal pending'; await atomic(receiptFile, encode(result));
delete registry.orgs[id];
});
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
} catch (e) {
e.deleteStep = phase; if (result) e.deleteReceipt = result;
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
throw e;
}
phase = 'inspect';
await withRegistry(ctx, async registry => {
const entry = registry.orgs[id];
if (!entry || entry.state !== 'deleting' || entry.deletion?.folder !== deletion.folder) fail('org-mismatch', 'deletion marker changed');
const owned = [
{ base: ctx.dataRoot, rel: `org-runtime/${id}`, to: 'runtime', required: true },
{ base: ctx.configDir, rel: `businesses/${id}.json`, to: 'organization.json', required: true },
{ base: ctx.configDir, rel: `businesses/.history/${id}`, to: 'history' },
...['agents', 'grants', 'hosts'].map(kind => ({ base: ctx.configDir, rel: `secrets/${kind}/${id}`, to: `secrets-${kind}`, secret: true })),
];
// Inspect all sources and destinations before moving the first one.
for (const item of owned) {
item.from = join(item.base, item.rel); item.dest = join(destination, item.to);
const parents = await deleteParents(item.base, item.rel);
item.count = parents ? await deleteInspect(item.from, { secret: item.secret }) : null;
const prior = result.moves.find(m => m.from === item.from);
const destCount = await deleteInspect(item.dest, { secret: item.secret });
if (prior && (item.count !== null || destCount === null)) fail('org-mismatch', 'moved path does not match receipt');
if (!prior && item.count !== null && destCount !== null) fail('org-mismatch', 'deletion destination already exists');
if (!prior && item.count === null && destCount !== null) item.recovered = true;
if (!prior && item.count === null && destCount === null && item.required) fail('org-mismatch', 'required organization path is missing');
if (item.recovered) item.count = destCount;
}
phase = 'move';
for (const item of owned) {
if (result.moves.some(m => m.from === item.from) || item.count === null) continue;
if (!item.recovered) await fs.rename(item.from, item.dest);
result.moves.push({ from: item.from, to: item.dest });
if (item.secret) result.secrets.push({ path: item.from, entryCount: item.count });
await atomic(receiptFile, encode(result));
}
phase = 'registry';
const log = join(deleted, 'deleted.log');
const fd = await fs.open(log, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe deletion log');
const lines = (await fd.readFile('utf8')).split('\n');
if (!lines.some(line => { try { return JSON.parse(line).destination === destination; } catch { return false; } })) await fd.writeFile(JSON.stringify({ org: id, destination, time: deletion.time, actor: deletion.actor, revision: deletion.revision }) + '\n');
} finally { await fd.close(); }
result.registry = 'removal pending'; await atomic(receiptFile, encode(result));
delete registry.orgs[id];
});
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
} catch (e) {
e.deleteStep = phase; if (result) e.deleteReceipt = result;
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
throw e;
}
});
}
+35 -2
View File
@@ -87,10 +87,10 @@ test('deletion refuses to move files while an in-flight bus start holds its life
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, 'deleting');
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' });
await org.deleteOrg(c, 'alpha', { busStop, confirm: 'alpha', ifRevision: 1 });
});
test('resume refuses a replaced data root symlink', async () => {
@@ -161,3 +161,36 @@ test('resume refuses an instance rebound to a different data root', async () =>
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);
});
+3 -2
View File
@@ -27,7 +27,8 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return nativeFetch(input, init);
};
const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(r => r.ok ? r.json() : []).then(rows => Array.isArray(rows) ? rows : []).catch(() => []);
const list = readList();
let list = readList();
const refreshList = () => (list = readList());
const paintHeader = rows => {
const org = rows.find(row => row.id === decodeURIComponent(id));
const mark = document.querySelector('.wordmark');
@@ -64,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
}
previous = next;
}, true);
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, list, readList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
window.MosaicOrg = Object.freeze({ id: decodeURIComponent(id), route, get list() { return list; }, readList, refreshList, switchTo: next => { location.hash = `#/orgs/${encodeURIComponent(next)}${route()}`; } });
})();
if (document.documentElement.hasAttribute('data-settings-proxy-document') && !location.hash) location.hash = '#/settings/model-proxy';
/* Browser-local sidebar density. No authority or project data is stored here. */
+3
View File
@@ -78,3 +78,6 @@
.org-quick-risk{display:flex;align-items:flex-start;gap:8px;margin:12px 0;line-height:1.5}.org-quick-risk input{margin-top:4px}.settings-content .org-path-actions button{display:inline-flex;align-items:center;justify-content:center}
.org-delete{margin-top:24px;border-color:color-mix(in srgb,var(--danger) 50%,var(--line))}.org-delete h3{color:var(--danger)}.org-delete input{display:block;max-width:100%;margin-top:8px}.org-delete ul{line-height:1.55}.org-delete #org-delete-final,.org-delete #org-delete-open{background:var(--danger);color:var(--surface)}
.org-delete #org-delete-first{padding:var(--pad)}
.org-delete #org-delete-second .settings-actions{margin-top:var(--gap)}
+8 -6
View File
@@ -227,7 +227,7 @@
function openEditor(mode, id, doc, { revision = null, held = [], verb = '' } = {}) {
Object.assign(st, { mode, id, doc, revision, held, errors: [] });
editorDirty = false; setupDirty.clear(); $('org-new-id').value = '';
$('org-editor').hidden = false; $('org-delete').hidden = mode !== 'edit'; $('org-delete-first').hidden = true; $('org-delete-second').hidden = true; $('org-delete-success').hidden = true;
$('org-editor').hidden = false; $('org-delete').hidden = mode !== 'edit'; resetDelete(); $('org-delete-success').hidden = true;
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
$('org-editor-verb').innerHTML = verb ? `Same as <code>${txt(verb)}</code>` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
@@ -491,9 +491,10 @@
if (e.target.matches('[data-risk]')) e.target.removeAttribute('aria-invalid');
}
function cancelDelete() {
$('org-delete-first').hidden = true; $('org-delete-second').hidden = true; $('org-delete-open').hidden = false; $('org-delete-confirm').value = ''; $('org-delete-final').disabled = true; $('org-delete-error').textContent = ''; $('org-delete-progress').innerHTML = ''; $('org-delete-open').focus();
function resetDelete() {
$('org-delete-first').hidden = true; $('org-delete-second').hidden = true; $('org-delete-open').hidden = false; $('org-delete-confirm').value = ''; $('org-delete-final').disabled = true; $('org-delete-error').textContent = ''; $('org-delete-progress').innerHTML = '';
}
function cancelDelete() { resetDelete(); $('org-delete-open').focus(); }
async function runDelete() {
const id = st.id, revision = st.revision, button = $('org-delete-final');
button.disabled = true; $('org-delete-error').textContent = ''; $('org-delete-progress').innerHTML = '<p>Deletion in progress…</p>';
@@ -501,22 +502,23 @@
const r = await api('POST', `/api/orgs/${encodeURIComponent(id)}/delete`, { confirm: $('org-delete-confirm').value, revision });
if (!alive) return;
if (r.status !== 200) {
$('org-delete-error').textContent = r.body.message || `Deletion stopped (${r.body.error || r.status}). Inspect the receipt and resume with the CLI.`;
$('org-delete-error').textContent = `The outcome is unknown. ${r.body.message || `Deletion stopped (${r.body.error || r.status}).`} Inspect the receipt and resume with the CLI.`;
const receipt = r.body.receipt;
$('org-delete-progress').innerHTML = `<p>Same as <code>${txt(r.body.verb || `mosaic org delete ${id} --confirm ${id}`)}</code></p><ol class="org-steps"><li class="panel org-step">Bus: ${receipt?.bus ? 'Done' : r.body.step === 'bus' ? 'Stopped' : 'Not started'}</li><li class="panel org-step">Local files: ${receipt?.moves?.length || 0} moved; ${txt(r.body.step || 'deletion')} stopped</li><li class="panel org-step">Registry: ${receipt?.registry === 'removed' ? 'Done' : 'Not complete'}</li></ol>`;
status('Deletion stopped.'); return;
}
if (r.body.outcome !== 'done' || r.body.step !== 'delete') throw Error('unknown deletion outcome');
editorDirty = false; setupDirty.clear(); $('org-editor').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true;
const box = $('org-delete-success');
box.innerHTML = `<h3>Organization deleted</h3><p>Same as <code>${txt(r.body.verb)}</code></p><ol class="org-steps"><li class="panel org-step org-step-done">Bus stopped or was already stopped: Done</li><li class="panel org-step org-step-done">Local files moved to the deleted folder: Done</li><li class="panel org-step org-step-done">Organization removed from the list: Done</li></ol><details><summary>Receipt</summary><pre class="org-pre">${txt(JSON.stringify(r.body.receipt, null, 2))}</pre></details>`;
const rows = window.MosaicOrg ? await window.MosaicOrg.refreshList() : [];
await refreshList(); box.hidden = false; box.focus(); status('Organization deleted.');
if (window.MosaicOrg?.id === id) {
const rows = await window.MosaicOrg.readList();
const next = rows.find(row => row.id !== id);
const hash = next ? `#/orgs/${encodeURIComponent(next.id)}/settings/organizations` : '#/settings/organizations';
history.replaceState(null, '', hash); location.reload();
}
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; }
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `<p>Resume with <code>${txt(`mosaic org delete ${id} --confirm ${id}`)}</code></p>`; }
}
// ---- wiring -----------------------------------------------------------
@@ -22,7 +22,7 @@ test('two confirmations delete an organization and remove it from the list', { t
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
async function shots(state, selector) {
if (!process.env.ORG_DELETE_SHOTS) return;
if (!process.env.ORG_DELETE_SHOTS || (process.env.ORG_DELETE_SHOT_STATES && !process.env.ORG_DELETE_SHOT_STATES.split(',').includes(state))) return;
await mkdir(process.env.ORG_DELETE_SHOTS, { recursive: true });
for (const [width, height] of [[1400, 900], [390, 844]]) for (const mode of ['light', 'dark']) {
await b.viewport(width, height);
@@ -75,3 +75,90 @@ test('deleting the current organization switches the Console to a remaining orga
for (let i = 0; i < 300 && !switched; i++) { try { switched = await b.evaluate(`window.MosaicOrg?.id === 'beta' && document.querySelector('header')?.textContent.includes('beta')`); } catch {} if (!switched) await new Promise(r => setTimeout(r, 30)); }
assert.equal(switched, true); assert.deepEqual((await registry.listOrgs(ctx)).map(e => e.id), ['beta']);
});
async function reviewFixture(t, ids = ['alpha', 'beta']) {
const dir = await mkdtemp(join(tmpdir(), 'review-delete-')); t.after(() => rm(dir, { recursive: true, force: true }));
const configDir = join(dir, 'config'), dataRoot = join(dir, 'data');
await mkdir(configDir, { mode: 0o700 }); await mkdir(dataRoot, { mode: 0o700 });
const ctx = { configDir, dataRoot, instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
for (const id of ids) await service.createOrg(ctx, id, await service.templateRender(id));
const organizations = {
list: async () => (await registry.listOrgs(ctx)).filter(e => e.state === 'active').map(e => ({ id: e.id, label: e.id, busHealthy: false })),
resolve: async id => (await registry.listOrgs(ctx)).some(e => e.id === id && e.state === 'active') ? { reads: { business: async () => ({ business: { id, projects: ['work'] } }), settings: async () => ({}) }, bus: {}, send: async () => ({}) } : null,
};
const admin = createOrgAdmin({ service, registry, bus: { busStatus: async () => ({ healthy: false }), busStopForDelete }, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), orgAdmin: admin, organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = async expression => { for (let i=0;i<300;i++) { try { if (await b.evaluate(expression)) return; } catch {} await new Promise(r=>setTimeout(r,30)); } throw Error(expression); };
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/organizations`);
await wait(`document.querySelector('#org-open-id')?.value === 'alpha'`);
async function open(id) {
await b.evaluate(`document.querySelector('#org-open-id').value=${JSON.stringify(id)};document.querySelector('#org-open-form button').click()`);
await wait(`document.querySelector('#org-editor-title')?.textContent === ${JSON.stringify(`Organization: ${id}`)} && !document.querySelector('#org-delete').hidden`);
}
async function confirm(id) {
await open(id);
await b.evaluate(`document.querySelector('#org-delete-open').click();document.querySelector('#org-delete-continue').click();const i=document.querySelector('#org-delete-confirm');i.value=${JSON.stringify(id)};i.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#org-delete-final').click()`);
await wait(`!document.querySelector('#org-delete-success').hidden || !!document.querySelector('#org-delete-error').textContent`);
}
return { b, wait, open, confirm, ctx };
}
test('review: reject a 200 response without the delete outcome and step', async t => {
const f = await reviewFixture(t);
await f.b.evaluate(`const originalFetch=window.fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/delete') ? Promise.resolve(new Response(JSON.stringify({outcome:'unknown',step:'bus'}),{status:200,headers:{'Content-Type':'application/json'}})) : originalFetch(input,init)`);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha','beta']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-success').hidden`), true, 'uncertain response must not announce successful deletion');
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-error').textContent`), /outcome is unknown/i);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-progress').textContent`), /mosaic org delete beta --confirm beta/);
});
for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503, { message: 'Unavailable' }], [200, { outcome: 'done', step: 'bus' }], [200, {}]]) {
test(`delete treats status ${status} and ${JSON.stringify(body)} as unknown`, async t => {
const f = await reviewFixture(t);
await f.b.evaluate(`const originalFetch=window.fetch.bind(window);window.fetch=(input,init)=>String(input).endsWith('/delete') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(body))},{status:${status},headers:{'Content-Type':'application/json'}})) : originalFetch(input,init)`);
await f.confirm('beta');
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-success').hidden`), true);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-error').textContent`), /outcome is unknown/i);
assert.match(await f.b.evaluate(`document.querySelector('#org-delete-progress').textContent`), /mosaic org delete beta --confirm beta/);
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e => e.id), ['alpha', 'beta']);
});
}
test('review: deleting a non-current org refreshes the organization switcher', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
await f.wait(`!!document.querySelector('#org-picker')`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
});
test('review: another org can be deleted after non-current deletion without reloading', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
await f.open('alpha');
assert.equal(await f.b.evaluate(`document.querySelector('#org-delete-open').hidden`), false, 'opening another org must restore its delete trigger');
});
test('opening another editor clears every deletion control', async t => {
const f = await reviewFixture(t);
await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-delete-open').click(); document.querySelector('#org-delete-continue').click(); const i=document.querySelector('#org-delete-confirm'); i.value='beta'; i.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('#org-delete-error').textContent='old error'; document.querySelector('#org-delete-progress').textContent='old progress'`);
await f.open('alpha');
assert.deepEqual(await f.b.evaluate(`({trigger:document.querySelector('#org-delete-open').hidden, first:document.querySelector('#org-delete-first').hidden, second:document.querySelector('#org-delete-second').hidden, value:document.querySelector('#org-delete-confirm').value, error:document.querySelector('#org-delete-error').textContent, progress:document.querySelector('#org-delete-progress').textContent, disabled:document.querySelector('#org-delete-final').disabled})`), {trigger:false,first:true,second:true,value:'',error:'',progress:'',disabled:true});
});
test('both confirmation panels have room around their content', async t => {
const f = await reviewFixture(t); await f.b.viewport(390, 844); await f.open('beta');
await f.b.evaluate(`document.querySelector('#org-delete-open').click()`);
const padded = await f.b.evaluate(`parseFloat(getComputedStyle(document.querySelector('#org-delete-first')).paddingLeft) > 0`);
await f.b.evaluate(`document.querySelector('#org-delete-continue').click()`);
const gap = await f.b.evaluate(`document.querySelector('#org-delete-second .settings-actions').getBoundingClientRect().top - document.querySelector('#org-delete-confirm').getBoundingClientRect().bottom > 0`);
assert.deepEqual({ padded, gap }, { padded: true, gap: true });
});