Fix organization deletion review findings
This commit is contained in:
1 parent
047825460f
commit
614559a94c
8 files changed
+235
-84
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)}
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user