Files
stack/packages/webui/tests/org-admin.test.mjs
jason.woltje d23673cd3e feat(org): copy credential binding CB0 and CB1 (#1873)
CB0: registry incarnation minted at reserve (legacy entries get one at
the first binding proposal, under the registry lock); copyId, source
and definition pin, binding generation; a pure resolver where
effective = role intersect approved and only a bound, matching,
unshadowed record resolves.

CB1: copyBindings in the organization file (ids, scope names, project
ids and integers only). applyCopyBindingDecision is its only writer,
behind a module-private capability every other commit is fenced
against. A binding is an owner-only subtype of the hire decision:
only the chief raises it, no autonomy level, resolver agent, member
right, tester or reader resolves it. Digest, expiry, single
consumption, revision, generation, incarnation and definition pin are
rechecked before any effect; a bind writes pending, an unbind the
unbound tombstone, receipts are paths only. Nothing writes bound, so
nothing launches on a binding yet (CB2/CB3). The org service and the
Console update path keep copyBindings server-held. Eight new refusal
codes with next steps.
2026-11-11 14:27:08 -06:00

689 lines
51 KiB
JavaScript

// Stream S6: organization create and setup handlers (src/org-admin.mjs),
// through the real Console under the dev local owner, with fake C2/C3 services.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin, orgAdminHTTP, browserProblems, pathFromMessage, toCanonical, forBrowser, plainMessage } from '../src/org-admin.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
const template = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' }, assistant: { definition: 'assistant' } }, projects: { work: { root: `/__mosaic_org__/${id}/projects/work` } } });
// In-memory C1/C2/C3 fakes with the PLAN.md signatures. `fail` injects errors.
function fakes() {
const orgs = new Map(), calls = [], fail = {};
const receipt = (op, org, extra = {}) => ({ op, org, at: '2026-11-07T00:00:00Z', subject: 'local-dev:local-dev:local-dev-owner', ...extra });
const get = id => { const o = orgs.get(id); if (!o) throw new OrgError('org-unknown'); return o; };
const service = {
templateRender: async id => template(id),
validateOrg: async doc => doc.roles?.chief?.definition === 'no-such-role' ? { ok: false, errors: [{ path: '$', message: '/businesses/acme.json roles.chief.definition names no role definition' }] } : { ok: true, errors: [] },
createOrg: async (ctx, id, doc) => { calls.push(['createOrg', ctx, id, doc]); if (orgs.has(id)) throw new OrgError('org-exists'); orgs.set(id, { doc, revision: 1, queue: null, repos: {} }); return receipt('org.create', id, { revision: 1 }); },
showOrg: async (ctx, id) => { const o = get(id); if (o.reserved) throw new OrgError('org-unavailable', 'organization is not active'); return { id, root: `/data/org-runtime/${id}`, revision: o.revision, doc: structuredClone(o.doc), principal: ctx.principal }; },
updateOrg: async (ctx, id, doc, { ifRevision }) => { calls.push(['updateOrg', ctx, id, doc, ifRevision]); const o = get(id); if (o.revision !== ifRevision) throw new OrgError('revision-conflict'); o.doc = doc; o.revision++; return receipt('org.update', id, { revision: o.revision }); },
// Like C2: no receipt is org-unavailable "setup receipt unavailable", not null.
queueShow: async (ctx, id) => { if (fail.readBack) throw new OrgError('org-mismatch', 'checkout is unsafe'); const q = get(id).queue; if (!q) throw new OrgError('org-unavailable', 'setup receipt unavailable'); return q; },
queueInit: async (ctx, id, opts) => { calls.push(['queueInit', id, opts]); const o = get(id); if (o.queue) throw new OrgError('org-exists', 'repository was already initialized; inspect its receipt'); if (opts.mode === 'local-only' && opts.acceptLocalLossRisk !== true) throw new OrgError('org-risk', 'local-only needs the explicit risk'); o.queue = receipt('org.queue.init', id, { mode: opts.mode }); if (fail.queue) throw new Error('lost'); return o.queue; },
projectRepoShow: async (ctx, id, project) => { const o = get(id); if (!o.doc.projects?.[project]) throw new OrgError('org-unknown', 'unknown project'); const r = o.repos[project]; if (!r) throw new OrgError('org-unavailable', 'setup receipt unavailable'); return r; },
projectRepoInit: async (ctx, id, project, opts) => { calls.push(['projectRepoInit', id, project, opts]); const o = get(id); if (fail.project === 'before') throw new Error('crashed'); o.repos[project] = receipt('org.project.repo.init', id, { project, mode: opts.mode }); if (fail.project === 'after') throw new Error('timeout'); return o.repos[project]; },
};
const registry = { listOrgs: async () => [...orgs.keys()].map(id => ({ id, state: orgs.get(id).reserved ? 'reserved' : 'active' })) };
let running = null;
const bus = {
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
};
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
return { admin, orgs, calls, fail, service };
}
async function console_(t, opts = {}) {
const f = fakes();
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, ...opts });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`;
const call = (method, path, body, headers = {}) => new Promise((resolve, reject) => {
const data = body === undefined ? null : typeof body === 'string' ? body : JSON.stringify(body);
const base = method === 'GET' ? { host } : { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const all = { ...base, ...headers }; for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (data !== null) all['content-length'] = Buffer.byteLength(data);
const req = request({ host: '127.0.0.1', port, path, method, headers: all }, res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, body: json, raw }); }); });
req.on('error', reject); req.end(data ?? undefined);
});
return { ...f, call, port };
}
const browserDoc = id => forBrowser(template(id)).doc;
test('real theme and setup handlers are registered with organization routing', async t => {
const organizations = { list: async () => [], resolve: async id => id === 'acme' ? { busHealthy: false } : null };
const c = await console_(t, { organizations });
assert.equal((await c.call('GET', '/api/themes')).status, 200);
assert.equal((await c.call('GET', '/api/org-template?id=acme')).status, 200);
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
assert.equal((await c.call('GET', '/api/orgs/acme/setup')).status, 200);
assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201);
});
test('the template comes from templateRender without server-held paths', async t => {
const c = await console_(t);
const r = await c.call('GET', '/api/org-template?id=acme');
assert.equal(r.status, 200);
assert.equal(r.body.doc.id, 'acme');
assert.deepEqual(r.body.doc.projects, { work: {} });
assert.deepEqual(r.body.held, ['$.projects.work.root']);
assert.equal(r.body.verb, 'mosaic org create acme --from <file>');
assert.equal((await c.call('GET', '/api/org-template?id=Bad%20Id')).status, 422);
});
test('validation names the field and its JSON path', async t => {
const c = await console_(t);
const doc = browserDoc('acme'); doc.arbiters.delivery = 'ghost'; delete doc.human; doc.roles.chief.definition = '';
const r = await c.call('POST', '/api/org-validate', { id: 'acme', doc });
assert.equal(r.status, 200); assert.equal(r.body.ok, false);
const paths = r.body.errors.map(e => e.path);
for (const p of ['$.human', '$.arbiters.delivery', '$.roles.chief.definition']) assert.ok(paths.includes(p), `${p} in ${paths}`);
assert.ok(r.body.errors.every(e => e.field && e.message));
// A loader message that names only "$" is mapped to the field it mentions.
const loader = browserDoc('acme'); loader.roles.chief.definition = 'no-such-role';
const l = await c.call('POST', '/api/org-validate', { id: 'acme', doc: loader });
assert.deepEqual(l.body.errors.map(e => e.path), ['$.roles.chief.definition']);
assert.equal(pathFromMessage('business requires "human"'), '$.human');
const ok = await c.call('POST', '/api/org-validate', { id: 'acme', doc: browserDoc('acme') });
assert.deepEqual(ok.body, { ok: true, errors: [] });
// Create refuses the same document with 422 and the same paths, before any write.
const created = await c.call('POST', '/api/orgs', { id: 'acme', doc });
assert.equal(created.status, 422); assert.ok(created.body.errors.some(e => e.path === '$.arbiters.delivery'));
assert.equal(c.calls.length, 0);
});
test('no filesystem path, shell text or token file is accepted from the browser', async t => {
const c = await console_(t);
const cases = [
[d => { d.projects.work.root = '/etc'; }, '$.projects.work.root', /set by the server/],
[d => { d.projects.work.repo = 'relative'; }, '$.projects.work.repo', /set by the server/],
[d => { d.projects.work.git = { mode: 'local-only' }; }, '$.projects.work.git', /set by the server/],
[d => { d.human = '/home/someone'; }, '$.human', /filesystem path/],
[d => { d.vars = { home: '~/x' }; }, '$.vars.home', /filesystem path/],
[d => { d.vars = { up: '../../etc/passwd' }; }, '$.vars.up', /filesystem path/],
[d => { d.vars = { url: 'file:///etc/passwd' }; }, '$.vars.url', /filesystem path/],
[d => { d.vars = { cmd: 'x; rm -rf ~' }; }, '$.vars.cmd', /shell text/],
[d => { d.vars = { cmd: '$(id)' }; }, '$.vars.cmd', /shell text/],
[d => { d.vars = { cmd: 'a`id`' }; }, '$.vars.cmd', /shell text/],
[d => { d.roles.chief.credentials = { gitea: { file: 'token', rotateBy: '2027-01-01' } }; }, '$.roles.chief.credentials.gitea.file', /secret references/],
[d => { d.roles.chief.credentials = { gitea: { env: 'lower case', rotateBy: '2027-01-01' } }; }, '$.roles.chief.credentials.gitea.env', /environment variable/],
[d => { d.vars = { 'bad key!': 'x' }; }, '$.vars.bad key!', /field names/],
];
for (const [edit, path, message] of cases) {
const doc = browserDoc('acme'); edit(doc);
const r = await c.call('POST', '/api/orgs', { id: 'acme', doc });
assert.equal(r.status, 422, path);
assert.ok(r.body.errors.some(e => e.path === path && message.test(e.message)), `${path}: ${JSON.stringify(r.body.errors)}`);
}
// An env or store reference is accepted.
const doc = browserDoc('acme'); doc.roles.chief.credentials = { gitea: { env: 'GITEA_TOKEN', rotateBy: '2027-01-01' } };
assert.deepEqual(browserProblems(doc), []);
assert.equal(c.calls.length, 0, 'nothing reached the service');
// The request body takes only id and doc: no path or command field.
const extra = await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme'), root: '/tmp/x' });
assert.equal(extra.status, 400); assert.equal(extra.body.errors[0].path, '$.root');
});
test('writes keep the origin and CSRF fencing', async t => {
const c = await console_(t);
const body = { id: 'acme', doc: browserDoc('acme') };
const refused = [
[{ origin: null }, 403],
[{ origin: 'http://evil.example' }, 403],
[{ origin: `http://localhost:${c.port + 1}` }, 403],
[{ 'sec-fetch-site': null }, 403],
[{ 'sec-fetch-site': 'cross-site' }, 403],
[{ 'sec-fetch-site': 'same-site' }, 403],
[{ 'content-type': 'text/plain' }, 415],
[{ 'content-type': 'application/x-www-form-urlencoded' }, 415],
];
for (const [headers, status] of refused) {
const r = await c.call('POST', '/api/orgs', body, headers);
assert.equal(r.status, status, JSON.stringify(headers));
}
for (const path of ['/api/orgs/acme/setup/queue', '/api/orgs/acme/setup/bus', '/api/orgs/acme/setup/records/work', '/api/orgs/acme/admin']) {
const r = await c.call('POST', path, {}, { 'sec-fetch-site': 'cross-site' });
assert.equal(r.status, 403); assert.equal(r.body.error, 'cross-site request refused');
}
assert.equal(c.calls.length, 0);
});
test('only the owner with --send writes', async t => {
const readOnly = await console_(t, { send: null });
assert.equal((await readOnly.call('GET', '/api/org-template?id=acme')).status, 200);
const r = await readOnly.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
// Without sign-in there is no owner: a loopback Console with no sso refuses every route.
const anon = await console_(t, { sso: null, send: null });
for (const [method, path] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-presets'], ['GET', '/api/org-admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs']]) {
const res = await anon.call(method, path, method === 'POST' ? { id: 'acme', doc: browserDoc('acme') } : undefined);
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only');
}
assert.equal(anon.calls.length + readOnly.calls.length, 0);
});
test('create calls createOrg with the canonical document and shows the receipt', async t => {
const c = await console_(t);
const r = await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
assert.equal(r.status, 201, r.raw);
assert.equal(r.body.receipt.op, 'org.create'); assert.equal(r.body.verb, 'mosaic org create acme --from <file>');
const [[op, ctx, id, doc]] = c.calls;
assert.equal(op, 'createOrg'); assert.equal(id, 'acme');
assert.deepEqual(ctx, { configDir: '/cfg', dataRoot: '/data', instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } });
assert.equal(doc.projects.work.root, '/__mosaic_org__/acme/projects/work', 'the server, not the browser, sets the placeholder root');
const again = await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
assert.equal(again.status, 409); assert.equal(again.body.readBack, '/api/orgs/acme/setup');
assert.deepEqual((await c.call('GET', '/api/org-admin')).body, { orgs: [{ id: 'acme', state: 'active' }] });
});
test('setup resumes from the read-back and never overwrites a receipt', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
let s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.status, 200);
assert.equal(s.body.queue.state, 'missing'); assert.equal(s.body.projects[0].state, 'missing'); assert.equal(s.body.bus.state, 'missing');
assert.equal(s.body.queue.verb, 'mosaic org queue init acme --local-only --accept-local-loss-risk');
assert.equal(s.body.projects[0].verb, 'mosaic org project repo init acme work --local-only --accept-local-loss-risk');
assert.equal(s.body.bus.verb, 'mosaic bus start acme --dev-local-owner --detach');
// Local only needs the explicit risk; remote needs an https URL.
let r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only' });
assert.equal(r.status, 422); assert.equal(r.body.error, 'risk-not-accepted'); assert.equal(r.body.errors[0].path, '$.acceptLocalLossRisk');
for (const remote of ['/srv/git', 'origin', 'ssh://git@host/acme/work', 'https://host/acme/$(id)', 'https://host/a b', 'file:///srv/git']) {
r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'remote', remote });
assert.equal(r.status, 422, remote); assert.equal(r.body.errors[0].path, '$.remote');
}
r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true, path: '/x' });
assert.equal(r.status, 400);
assert.equal(c.calls.filter(x => x[0] === 'queueInit').length, 0);
r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true });
assert.equal(r.status, 201); assert.equal(r.body.receipt.op, 'org.queue.init');
// Reload: the page resumes at the first step without a receipt.
s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.body.queue.state, 'done'); assert.equal(s.body.queue.receipt.op, 'org.queue.init'); assert.equal(s.body.projects[0].state, 'missing');
// A retry reads back first and returns the existing receipt.
r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'remote', remote: 'https://git.example.test/acme/work' });
assert.equal(r.status, 200); assert.equal(r.body.existing, true); assert.equal(r.body.receipt.mode, 'local-only');
assert.equal(c.calls.filter(x => x[0] === 'queueInit').length, 1, 'queueInit ran once');
// An unknown outcome stays incomplete; the retry reads back the receipt it left.
c.fail.project = 'after';
r = await c.call('POST', '/api/orgs/acme/setup/project/work', { mode: 'remote', remote: 'https://git.example.test/acme/work' });
assert.equal(r.status, 502); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.readBack, '/api/orgs/acme/setup');
c.fail.project = null;
r = await c.call('POST', '/api/orgs/acme/setup/project/work', { mode: 'remote', remote: 'https://git.example.test/acme/work' });
assert.equal(r.status, 200); assert.equal(r.body.existing, true);
assert.equal(c.calls.filter(x => x[0] === 'projectRepoInit').length, 1, 'projectRepoInit was not run again');
assert.equal((await c.call('POST', '/api/orgs/acme/setup/project/nope', { mode: 'remote', remote: 'https://git.example.test/acme/work' })).status, 404);
r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.status, 201); assert.equal(r.body.receipt.pid, 4242);
assert.deepEqual(c.calls.find(x => x[0] === 'busStart')[2], { detach: true, devLocalOwner: true, pm: false });
r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.status, 200); assert.equal(r.body.existing, true);
assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 1);
s = await c.call('GET', '/api/orgs/acme/setup');
assert.deepEqual([s.body.queue.state, s.body.projects[0].state, s.body.bus.state], ['done', 'done', 'done']);
});
test('a step whose receipt cannot be read back is never retried', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
c.fail.readBack = true;
const r = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true });
assert.equal(r.status, 409); assert.equal(r.body.error, 'readback-failed'); assert.match(r.body.message, /org-mismatch/);
assert.equal(c.calls.filter(x => x[0] === 'queueInit').length, 0);
const s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.body.queue.state, 'unknown'); assert.doesNotMatch(JSON.stringify(s.body), /checkout is unsafe/);
});
test('an organization whose creation did not finish is shown as inactive', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
c.orgs.get('acme').reserved = true;
for (const [method, path, body] of [['GET', '/api/orgs/acme/admin'], ['GET', '/api/orgs/acme/setup'], ['POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true }]]) {
const r = await c.call(method, path, body);
assert.equal(r.status, 409, path); assert.equal(r.body.error, 'org-inactive');
}
assert.equal(c.calls.filter(x => x[0] === 'queueInit').length, 0);
});
test('a bus that refuses or fails to start is a visible failure without host paths', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const bus = await c.admin.bus();
const cliError = (message, exitCode) => Object.assign(new Error(message), { name: 'CliError', exitCode });
bus.busStart = async () => { throw cliError('bus failed to start; see /data/org-runtime/acme/host/host.log', 3); };
let r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
// The host's own refusal line is shown with the failure; other log lines are not.
const root = mkdtempSync(join(tmpdir(), 'orgbus-')); t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus-host'));
writeFileSync(join(root, 'bus-host', 'host.log'), 'secret-looking noise\nmosaic: no notifier config at /data/org-runtime/acme/notify/acme/notify.json; write it\n');
const status = bus.busStatus; bus.busStatus = async (ctx, id) => ({ ...(await status(ctx, id)), root });
r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log. The bus host said: no notifier config at <data root>/org-runtime/acme/notify/acme/notify.json; write it');
assert.doesNotMatch(r.body.message, /noise/);
bus.busStatus = status; bus.busStart = async () => { throw cliError('organization acme bus already runs', 3); };
r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.body.error, 'bus-refused'); assert.equal(r.body.message, 'organization acme bus already runs');
bus.busStatus = async () => { throw cliError('stale bus host lock', 3); };
r = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused'); assert.equal(r.body.message, 'stale bus host lock');
const s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.body.bus.state, 'unknown'); assert.match(s.body.bus.message, /stale bus host lock/);
});
test('a failed step leaves no receipt and stays incomplete', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
c.fail.project = 'before';
const r = await c.call('POST', '/api/orgs/acme/setup/project/work', { mode: 'local-only', acceptLocalLossRisk: true });
assert.equal(r.status, 502); assert.equal(r.body.error, 'outcome-unknown');
const s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.body.projects[0].state, 'missing');
});
test('edit saves with if-revision semantics and shows conflicts plainly', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
c.orgs.get('acme').doc.projects.work.root = '/data/org-runtime/acme/projects/work';
c.orgs.get('acme').doc.roles.chief.credentials = { gitea: { file: 'gitea.token', rotateBy: '2027-01-01' } };
const shown = await c.call('GET', '/api/orgs/acme/admin');
assert.equal(shown.status, 200); assert.equal(shown.body.revision, 1);
assert.deepEqual(shown.body.doc.projects.work, {}, 'the stored root is not sent to the browser');
assert.equal(shown.body.doc.roles.chief.credentials.gitea, undefined, 'a token file reference is not sent');
assert.deepEqual(shown.body.held, ['$.projects.work.root', '$.roles.chief.credentials.gitea']);
const doc = shown.body.doc; doc.roles.assistant.definition = 'assistant-two';
let r = await c.call('POST', '/api/orgs/acme/admin', { doc, ifRevision: 1 });
assert.equal(r.status, 200, r.raw); assert.equal(r.body.receipt.revision, 2);
assert.equal(r.body.verb, 'mosaic org update acme --from <file> --if-revision 1');
const [, , , saved, rev] = c.calls.find(x => x[0] === 'updateOrg');
assert.equal(rev, 1);
assert.equal(saved.projects.work.root, '/data/org-runtime/acme/projects/work', 'the stored root is kept');
assert.deepEqual(saved.roles.chief.credentials.gitea, { file: 'gitea.token', rotateBy: '2027-01-01' }, 'the token file reference is kept');
// A stale revision is refused before updateOrg runs.
r = await c.call('POST', '/api/orgs/acme/admin', { doc, ifRevision: 1 });
assert.equal(r.status, 409); assert.equal(r.body.error, 'revision-conflict'); assert.match(r.body.message, /saved this organization after you opened it/);
assert.equal(c.calls.filter(x => x[0] === 'updateOrg').length, 1);
r = await c.call('POST', '/api/orgs/acme/admin', { doc });
assert.equal(r.status, 422); assert.equal(r.body.errors[0].path, '$.ifRevision');
});
test('an unknown organization answers 404 org-unavailable', async t => {
const c = await console_(t);
for (const [method, path, body] of [['GET', '/api/orgs/ghost/admin'], ['GET', '/api/orgs/ghost/setup'], ['POST', '/api/orgs/ghost/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true }], ['POST', '/api/orgs/ghost/admin', { doc: browserDoc('ghost'), ifRevision: 1 }], ['GET', '/api/orgs/..%2Fetc/setup']]) {
const r = await c.call(method, path, body);
assert.equal(r.status, 404, path); assert.deepEqual(r.body, { error: 'org-unavailable' });
}
});
test('a missing service answers not-configured', async t => {
const c = await console_(t, { orgAdmin: createOrgAdmin({ service: null, registry: null, bus: null, context: async () => null }) });
for (const [method, path, body] of [['GET', '/api/org-template?id=acme'], ['GET', '/api/org-admin'], ['POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') }], ['GET', '/api/orgs/acme/setup']]) {
const r = await c.call(method, path, body);
assert.equal(r.status, 503, path); assert.equal(r.body.error, 'not-configured');
}
// Without a named instance the answer says what is missing.
const f = fakes(); const service = await f.admin.service();
const n = await console_(t, { orgAdmin: createOrgAdmin({ service, registry: await f.admin.registry(), context: async () => null }) });
const none = await n.call('GET', '/api/org-admin');
assert.equal(none.status, 503); assert.match(none.body.message, /named instance/);
// A Console without the C3 bus module reads the bus step back as unavailable.
const p = await console_(t, { orgAdmin: createOrgAdmin({ service, registry: await f.admin.registry(), bus: null, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }) });
await p.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
assert.equal((await p.call('GET', '/api/orgs/acme/setup')).body.bus.state, 'unavailable');
assert.equal((await p.call('POST', '/api/orgs/acme/setup/bus', {})).status, 503);
});
test('GET /api/orgs and other organization routes stay with S5', async () => {
const res = { writeHead() { throw new Error('answered'); }, end() {} };
for (const [method, path] of [['GET', '/api/orgs'], ['GET', '/api/orgs/acme'], ['GET', '/api/orgs/acme/projects'], ['POST', '/api/orgs/acme']]) {
assert.equal(await orgAdminHTTP({ method, headersDistinct: {} }, res, { path, admin: null }), false, `${method} ${path}`);
}
});
test('messages keep URLs, show repository files relative and withhold other host paths', () => {
const repo = new URL('../../..', import.meta.url).pathname.replace(/\/$/, '');
const ctx = { dataRoot: '/scratch/h/.mosaic-x', configDir: '/scratch/h/.config/mosaic-x' };
assert.equal(plainMessage(ctx, `role file not found: ${repo}/roles/no-such.json`), 'role file not found: roles/no-such.json');
assert.equal(plainMessage(ctx, 'must be an https repository URL, such as https://git.example/owner/repo'), 'must be an https repository URL, such as https://git.example/owner/repo');
assert.equal(plainMessage(ctx, 'see /scratch/h/.mosaic-x/orgs/acme/host.log'), 'see <data root>/orgs/acme/host.log');
assert.equal(plainMessage(ctx, 'cannot read /etc/passwd'), 'cannot read <host path>');
});
test('toCanonical keeps library projects free of a root', () => {
const out = toCanonical('acme', { projects: { lib: { kind: 'library' }, work: {} } });
assert.deepEqual(out.projects, { lib: { kind: 'library' }, work: { root: '/__mosaic_org__/acme/projects/work' } });
});
// #1873 CB1: copy bindings are server-held. An update keeps the stored ones whatever the browser
// sent, and drops any the browser invented when none are stored.
test('toCanonical keeps stored copyBindings and drops browser-sent ones on an update', () => {
const stored = { id: 'acme', projects: { work: {} }, copyBindings: { rev: { state: 'pending', generation: 1 } } };
const kept = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound', generation: 9 } } }, stored);
assert.deepEqual(kept.copyBindings, stored.copyBindings);
const dropped = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound' } } }, { id: 'acme', projects: { work: {} } });
assert.equal(Object.hasOwn(dropped, 'copyBindings'), false);
const removed = toCanonical('acme', { projects: { work: {} } }, stored);
assert.deepEqual(removed.copyBindings, stored.copyBindings);
});
test('the Settings page ships the organization assets', async t => {
const c = await console_(t);
const index = await c.call('GET', '/');
assert.match(index.raw, /<script src="\/org-create\.js" defer><\/script>/);
assert.match(index.raw, /<link rel="stylesheet" href="\/org-create\.css">/);
const js = await c.call('GET', '/org-create.js');
assert.equal(js.status, 200); assert.match(js.raw, /window\.MosaicOrgCreate = \{ markup, mount \}/);
assert.equal((await c.call('GET', '/org-create.css')).status, 200);
const bus = readFileSync(new URL('../src/public/bus.js', import.meta.url), 'utf8');
assert.match(bus, /\['organizations','Organizations'\]/);
});
test('against the real C2 service: create, set up, resume and refuse an overwrite', async t => {
const service = await import('../../organization/src/org-service.mjs');
const registry = await import('../../organization/src/org-registry.mjs');
const { mkdtempSync, mkdirSync, existsSync } = await import('node:fs');
const { join } = await import('node:path'); const { tmpdir } = await import('node:os');
const root = mkdtempSync(join(tmpdir(), 'oa')); const configDir = join(root, 'config'), dataRoot = join(root, 'data');
mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
const admin = createOrgAdmin({ service, registry, bus: null, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const c = await console_(t, { orgAdmin: admin });
const tpl = await c.call('GET', '/api/org-template?id=acme');
assert.equal(tpl.status, 200, tpl.raw);
const bad = await c.call('POST', '/api/org-validate', { doc: { ...tpl.body.doc, human: 7 } });
assert.equal(bad.body.ok, false); assert.ok(bad.body.errors.some(e => e.path === '$.human'), JSON.stringify(bad.body));
const made = await c.call('POST', '/api/orgs', { id: 'acme', doc: tpl.body.doc });
assert.equal(made.status, 201, made.raw); assert.equal(made.body.receipt.op, 'org.create');
let s = await c.call('GET', '/api/orgs/acme/setup');
assert.equal(s.status, 200, s.raw); assert.deepEqual([s.body.queue.state, s.body.projects[0].state, s.body.bus.state], ['missing', 'missing', 'unavailable']);
assert.equal(s.body.notifications.state, 'done');
assert.equal(s.body.notifications.receipt.notifications, 'none');
const q = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true });
assert.equal(q.status, 201, q.raw); assert.equal(q.body.receipt.op, 'org.queue.init'); assert.ok(existsSync(join(dataRoot, 'org-runtime/acme/checkout/.git')));
const p = await c.call('POST', '/api/orgs/acme/setup/project/work', { mode: 'remote', remote: 'https://git.example.invalid/acme/work' });
assert.equal(p.status, 201, p.raw); assert.equal(p.body.receipt.remote, 'https://git.example.invalid/acme/work');
s = await c.call('GET', '/api/orgs/acme/setup');
assert.deepEqual([s.body.queue.state, s.body.projects[0].state], ['done', 'done']);
assert.equal(s.body.queue.receipt.root, '<data root>/org-runtime/acme/checkout');
assert.equal(s.body.projects[0].verb, 'mosaic org project repo init acme work --remote https://git.example.invalid/acme/work');
// A second attempt reads the receipt back and never reinitializes.
const again = await c.call('POST', '/api/orgs/acme/setup/queue', { mode: 'local-only', acceptLocalLossRisk: true });
assert.equal(again.status, 200); assert.equal(again.body.existing, true); assert.equal(again.body.receipt.commit, q.body.receipt.commit);
// The project step bumped the revision, so an edit from the old revision conflicts.
const shown = await c.call('GET', '/api/orgs/acme/admin');
assert.equal(shown.status, 200, shown.raw);
const stale = await c.call('POST', '/api/orgs/acme/admin', { doc: shown.body.doc, ifRevision: shown.body.revision - 1 });
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'revision-conflict');
const saved = await c.call('POST', '/api/orgs/acme/admin', { doc: shown.body.doc, ifRevision: shown.body.revision });
assert.equal(saved.status, 200, saved.raw); assert.equal(saved.body.receipt.op, 'org.update');
assert.doesNotMatch(JSON.stringify([tpl.body, s.body, shown.body]), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'no server path reaches the browser');
});
test('preset routes expose installed choices and refuse invalid queries', async t => {
const service = await import('../../organization/src/org-service.mjs');
const admin = createOrgAdmin({ service, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const c = await console_(t, { orgAdmin: admin });
const presets = await c.call('GET', '/api/org-presets');
assert.equal(presets.status, 200);
assert.ok(presets.body.presets.some(p => p.name === 'fixture' && p.available.ok));
const result = await c.call('GET', '/api/org-template?id=alpha&preset=fixture&thinking=off');
assert.equal(result.status, 200);
assert.deepEqual(result.body.doc.projects, { work: {} });
assert.equal(result.body.doc.roles.assistant.vars.model, 'fixture-deterministic');
for (const query of ['id=alpha&preset=unknown', 'id=alpha&preset=fixture&thinking=high', 'id=alpha&thinking=off', 'id=alpha&id=beta', 'id=alpha&other=x']) {
assert.equal((await c.call('GET', `/api/org-template?${query}`)).status, 422, query);
}
assert.equal((await c.call('GET', '/api/org-presets?extra=x')).status, 422);
});
test('organization delete route enforces write fence and validates confirmation and revision', async t => {
const c = await console_(t);
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1 }, { 'sec-fetch-site': 'cross-site' })).status, 403);
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'no', revision: 1 })).status, 422);
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 0 })).status, 422);
assert.equal((await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1, extra: true })).status, 400);
});
test('delete refusal exposes progress and redacts receipt paths', async t => {
const c = await console_(t);
c.service.deleteOrg = async () => { const e = new OrgError('org-mismatch', 'unsafe path /data/org-runtime/acme'); e.deleteStep = 'inspect'; e.deleteReceipt = { bus: { stopped: false, reason: 'no host runs' }, moves: [], destination: '/data/deleted/acme-x' }; throw e; };
const r = await c.call('POST', '/api/orgs/acme/delete', { confirm: 'acme', revision: 1 });
assert.equal(r.status, 409); assert.equal(r.body.step, 'inspect'); assert.equal(r.body.receipt.destination, '<data root>/deleted/acme-x');
assert.match(r.body.verb, /mosaic org delete acme --confirm acme/); assert.doesNotMatch(r.raw, /\/data\//);
});
test('archive and restore use fenced owner routes with strict revisions and injected bus stop', async t => {
const c = await console_(t);
c.service.archiveOrg = async (ctx, id, opts) => { assert.equal(typeof opts.busStop, 'function'); c.calls.push(['archiveOrg', id, opts.ifRevision]); return { org: id, revision: 2 }; };
c.service.restoreOrg = async (ctx, id, opts) => { c.calls.push(['restoreOrg', id, opts.ifRevision]); return { org: id, revision: 3 }; };
for (const step of ['archive', 'restore']) {
const path = `/api/orgs/acme/${step}`;
assert.equal((await c.call('POST', path, { revision: 1 }, { 'sec-fetch-site': 'cross-site' })).status, 403);
assert.equal((await c.call('POST', path, { revision: 1, extra: true })).status, 400);
assert.equal((await c.call('POST', path, { revision: 0 })).status, 422);
const response = await c.call('POST', path, { revision: 1 });
assert.equal(response.status, 200); assert.equal(response.body.outcome, 'done'); assert.equal(response.body.step, step);
}
assert.deepEqual(c.calls.map(c => c[0]), ['archiveOrg', 'restoreOrg']);
});
test('archive requires the injected bus stop and both lifecycle routes require owner writes', async t => {
let called = false;
const service = { archiveOrg: async () => { called = true; }, restoreOrg: async () => { called = true; } };
const admin = createOrgAdmin({ service, bus: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const c = await console_(t, { orgAdmin: admin });
assert.equal((await c.call('POST', '/api/orgs/acme/archive', { revision: 1 })).status, 503);
const readonly = await console_(t, { orgAdmin: admin, send: null });
for (const step of ['archive', 'restore']) assert.equal((await readonly.call('POST', `/api/orgs/acme/${step}`, { revision: 1 })).status, 403);
assert.equal(called, false);
});
// F3 (REQ-WEB-3): the owner restarts an organization's bus from the page, with a done receipt.
const trailIn = t => { const root = mkdtempSync(join(tmpdir(), 'f3-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'console'); return { dir, trail: actionTrail(dir), lines: () => { try { return readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } }; };
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('the owner starts a stopped bus and restarts a running one, and the trail records each', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
let r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
// A stopped bus starts as setup starts it, keeping the dev local owner.
assert.deepEqual(c.calls.filter(x => x[0] === 'busStart').map(x => x.slice(1)), [['acme', { detach: true, devLocalOwner: true, pm: false }]]);
r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.equal(r.body.restarted, true); assert.equal(r.body.healthy, true); assert.equal(r.body.verb, 'mosaic bus restart acme');
assert.equal(r.body.receipt.pid, 4343);
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
});
test('a bus restart from another origin or site, without the owner or --send, or for no organization is refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', {}, { 'content-type': 'text/plain' })).status, 415);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', { now: true })).status, 400);
assert.equal((await c.call('GET', '/api/orgs/acme/restart/bus')).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/queue', {})).status, 404);
const unknown = await c.call('POST', '/api/orgs/ghost/restart/bus', {});
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
const readOnly = await console_(t, { trail: tr.trail, send: null });
const ro = await readOnly.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
const a = await anon.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('a bus restart with no trail, or a trail that cannot be written, changes nothing', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
for (const x of [c, broken]) assert.equal(x.calls.filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
test('a bus that refuses to restart is a visible failure and the trail says refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const bus = await c.admin.bus();
bus.busRestart = async () => { throw Object.assign(new Error('bus failed to start; see /data/org-runtime/acme/host/host.log'), { name: 'CliError', exitCode: 3 }); };
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused']);
// A host whose status cannot be read (a stale lock) is refused before anything changes; the
// attempt is still in the trail, and nothing restarts or starts.
bus.busStatus = async () => { throw Object.assign(new Error('stale bus host lock'), { name: 'CliError', exitCode: 3 }); };
const before = c.calls.length;
const s = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(s.status, 409); assert.equal(s.body.error, 'bus-refused');
assert.equal(s.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
assert.deepEqual(tr.lines().slice(2), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'refused', running: null }]);
assert.equal(c.calls.slice(before).filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
// D2 (P0-3): on the dev Console, Restart bus on a host that died (its state left behind by a
// Console unit stop) clears that state through busRevive and starts the bus. busRevive's
// busRecover is the proof; a live or unprovable host refuses and nothing starts.
const cliError = message => Object.assign(new Error(message), { name: 'CliError', exitCode: 3 });
const withRevive = (c, revive) => c.admin.bus().then(bus => {
bus.busStatus = async () => { throw cliError('stale bus host lock'); };
bus.busRevive = async (ctx, id, opts) => { c.calls.push(['busRevive', id, opts]); return revive(bus, id); };
return bus;
});
test('D2: the dev owner\'s Restart bus brings back a host that died, and the trail records the recovery', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await withRevive(c, (bus, id) => {
bus.busStatus = async () => ({ org: id, healthy: true, pid: 5151 });
return { org: id, pid: 5151, socket: '/data/org-runtime/acme/bus/broker.sock', root: '/data/org-runtime/acme', recovered: { pid: 4242, receipt: '/data/org-runtime/acme/bus-host/recovery-1.json' } };
});
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, recovered: true, healthy: true,
receipt: { org: 'acme', pid: 5151, socket: '<data root>/org-runtime/acme/bus/broker.sock', root: '<data root>/org-runtime/acme', recovered: { pid: 4242, receipt: '<data root>/org-runtime/acme/bus-host/recovery-1.json' } },
verb: 'mosaic bus recover acme; mosaic bus start acme --dev-local-owner --detach' });
assert.deepEqual(c.calls.filter(x => ['busRevive', 'busStart', 'busRestart'].includes(x[0])), [['busRevive', 'acme', undefined]]);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false, recover: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done', recover: true },
]);
});
test('D2: a host that is alive or unprovable still refuses on the dev Console, and nothing starts', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
for (const reason of ['bus host pid 4242 is alive', 'bus host lock does not match host state', 'bus socket answers or is uncertain', 'bus host identity is uncertain']) {
await withRevive(c, () => { throw cliError(reason); });
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 409, reason); assert.equal(r.body.error, 'bus-refused');
assert.equal(r.body.message, `${reason}. Inspect it with mosaic bus status acme.`);
}
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'refused', 'requested', 'refused', 'requested', 'refused']);
// A host that was cleared but did not come back says so, without host paths.
await withRevive(c, () => { throw Object.assign(cliError('bus failed to start; see bus-host/host.log in the runtime directory of organization acme'), { recovered: { pid: 4242 } }); });
const f = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(f.status, 409); assert.equal(f.body.error, 'bus-failed');
assert.equal(f.body.message, "The dead host's state was cleared, but bus failed to start; see bus-host/host.log in the runtime directory of organization acme");
assert.deepEqual(tr.lines().slice(-1), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'failed', recover: true }]);
// Another lifecycle action holds the organization's lock: nothing changed, and the page says to press again.
await withRevive(c, () => { throw Object.assign(new Error('organization lifecycle lock is held; inspect the lock and resume'), { code: 'org-locked' }); });
const l = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(l.status, 409); assert.equal(l.body.error, 'bus-busy');
assert.equal(l.body.message, 'another bus or lifecycle action on acme is running, and nothing was changed. Press Restart bus again when it ends.');
assert.deepEqual(tr.lines().slice(-1).map(e => e.outcome), ['refused']);
// No trail, no recovery.
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await withRevive(broken, () => assert.fail('busRevive ran without a trail line'));
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
});
test('D2: a signed-in (non-dev) owner keeps the stale-state refusal and never reaches busRevive', async t => {
const { Readable } = await import('node:stream');
const tr = trailIn(t);
const f = fakes();
await f.service.createOrg({}, 'acme', template('acme'));
const bus = await f.admin.bus();
bus.busStatus = async () => { throw cliError('stale bus host lock'); };
bus.busRevive = async () => assert.fail('busRevive reached from a signed-in session');
const call = async session => {
const req = Object.assign(Readable.from([Buffer.from('{}')]), { method: 'POST', headersDistinct: { origin: ['http://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'] } });
const res = { writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } };
await orgAdminHTTP(req, res, { path: '/api/orgs/acme/restart/bus', admin: f.admin, session, owner: true, send: true, origin: 'http://console.example', trail: tr.trail });
return res;
};
// An SSO owner, and a session that names the local-dev provider under another issuer.
for (const session of [{ provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }, { provider: 'local-dev', iss: 'https://issuer.example', sub: 'local-dev-owner', rootOwner: true }]) {
const r = await call(session);
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused');
assert.equal(r.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
}
assert.deepEqual(tr.lines().map(e => [e.outcome, e.running, e.recover]), [['refused', null, undefined], ['refused', null, undefined]]);
});
test('D2: a press during the Console start-up revive waits for it and acts on the bus it brought back', async t => {
const { reviveBuses } = await import('../src/bus-revive.mjs');
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const bus = await c.admin.bus();
let up = false, release;
const gate = new Promise(resolve => { release = resolve; });
bus.busStatus = async () => { if (!up) throw cliError('stale bus host lock'); return { org: 'acme', healthy: true, pid: 6161 }; };
bus.busRevive = async () => assert.fail('the press revived a bus the Console was already bringing back');
bus.busRestart = async (ctx, id) => { c.calls.push(['busRestart', id]); return { org: id, pid: 6262 }; };
const startup = reviveBuses({ ctx: {}, env: {}, listOrgs: async () => [{ id: 'acme', state: 'active' }], busStatus: bus.busStatus,
busRevive: async () => { await gate; up = true; return { pid: 6161 }; }, trail: () => {}, log: () => {} });
await new Promise(resolve => setImmediate(resolve));
const press = c.call('POST', '/api/orgs/acme/restart/bus', {});
await new Promise(resolve => setTimeout(resolve, 50));
assert.equal(c.calls.filter(x => x[0] === 'busRestart').length, 0);
release();
await startup;
const r = await press;
assert.equal(r.status, 200, r.raw); assert.equal(r.body.restarted, true);
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
});