Files
stack/packages/webui/tests/org-admin.test.mjs
jason.woltje a5f227e9fc fix(console): Stop bus review round: trail and stopped flag (#1873)
A stop that may already have signalled the host (a timeout or an
unexpected error) is trailed as failed, not refused; only exit 3 comes
before the signal. The response's stopped flag follows what busStop
reports, so a host that exited on its own between the read and the stop
is not reported as stopped by this press.
2026-11-11 16:10:48 -06:00

884 lines
65 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}` }; },
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
};
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']]);
});
// Stop bus: the owner stops one organization's bus from its page. Same owner, origin, --send and
// trail rules as Restart bus; a stopped bus answers state stopped and nothing changes.
const STOP = '/api/orgs/acme/stop/bus';
const STOP_DONE = { outcome: 'done', step: 'bus-stop', stopped: true, state: 'stopped', receipt: { org: 'acme', stopped: true, business: 'acme', pid: 4242 }, verb: 'mosaic bus stop acme' };
test('stop: the owner stops a running bus, a second press answers stopped, and setup starts it again', 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);
assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201);
let r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, STOP_DONE);
r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-stop', stopped: false, state: 'stopped', receipt: { org: 'acme', stopped: false }, verb: 'mosaic bus stop acme' });
// The second press changed nothing: busStop ran once.
assert.deepEqual(c.calls.filter(x => x[0] === 'busStop'), [['busStop', 'acme']]);
const up = await c.call('POST', '/api/orgs/acme/setup/bus', {});
assert.equal(up.status, 201, up.raw); assert.equal(up.body.step, 'bus');
assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 2);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
]);
});
test('stop: another origin or site, no owner or --send, a body, GET or no organization is refused like Restart bus', 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', {});
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 [stop, restart] = [await c.call('POST', STOP, {}, headers), await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers)];
assert.equal(stop.status, 403, JSON.stringify(headers));
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error], JSON.stringify(headers));
}
assert.equal((await c.call('POST', STOP, {}, { 'content-type': 'text/plain' })).status, 415);
assert.equal((await c.call('POST', STOP, { now: true })).status, 400);
assert.equal((await c.call('GET', STOP)).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/stop/queue', {})).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/stop', {})).status, 404);
const unknown = await c.call('POST', '/api/orgs/ghost/stop/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', STOP, {});
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', STOP, {});
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('stop: on a deployed (signed-in) Console a granted user is refused like Restart bus, and the owner stops the bus', async t => {
const { createSsoSession } = await import('./helpers/sso-session.mjs');
const { consoleAccess } = await import('../src/access.mjs');
const tr = trailIn(t);
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'acme', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'acme' }) });
const f = fakes();
await f.service.createOrg({}, 'acme', template('acme'));
const bus = await f.admin.bus();
await bus.busStart({}, 'acme', {});
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: tr.trail });
t.after(() => close(server));
const port = server.address().port;
const post = (path, cookie, headers = {}) => new Promise((resolve, reject) => {
const all = { ...s.writeHeaders(cookie), 'content-length': 2, ...headers };
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers: all }, res => { let raw = ''; res.on('data', x => raw += x); 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('{}');
});
const alice = await s.signIn(port, { sub: 'alice-sub' });
for (const cookie of [null, alice]) {
const [stop, restart] = [await post(STOP, cookie), await post('/api/orgs/acme/restart/bus', cookie)];
assert.ok(stop.status === 401 || stop.status === 403 || stop.status === 404, stop.raw);
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error]);
}
assert.equal(f.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
// The signed-in owner from another origin is refused too; from the Console's own origin the bus stops.
const owner = await s.signIn(port);
const cross = await post(STOP, owner, { origin: 'https://evil.example' });
assert.equal(cross.status, 403);
const ok = await post(STOP, owner);
assert.equal(ok.status, 200, ok.raw); assert.equal(ok.body.state, 'stopped'); assert.equal(ok.body.stopped, true);
assert.deepEqual(tr.lines().map(e => [e.by.provider, e.by.sub, e.action, e.outcome]), [['writer', 'owner-sub', 'bus.stop', 'requested'], ['writer', 'owner-sub', 'bus.stop', 'done']]);
});
test('stop: a signed-in operator who is not the owner is refused before any bus call', async t => {
const { Readable } = await import('node:stream');
const tr = trailIn(t);
const f = fakes();
await f.service.createOrg({}, 'acme', template('acme'));
for (const path of [STOP, '/api/orgs/acme/restart/bus']) {
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, admin: f.admin, session: { provider: 'fixture', iss: 'https://issuer.example', sub: 'op', rootOwner: false, instanceRole: 'operator' }, owner: false, send: true, origin: 'http://console.example', trail: tr.trail });
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only', path);
}
assert.equal(f.calls.filter(x => x[0].startsWith('bus')).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('stop: no trail, or a trail that cannot be written, stops nothing', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const r = await c.call('POST', STOP, {});
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') });
await broken.call('POST', '/api/orgs/acme/setup/bus', {});
const b = await broken.call('POST', STOP, {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
for (const x of [c, broken]) assert.equal(x.calls.filter(y => y[0] === 'busStop').length, 0);
});
test('stop: a host that cannot be proven refuses with nothing changed, on the dev Console too', 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') });
const bus = await c.admin.bus();
bus.busRevive = async () => assert.fail('Stop bus reached busRevive');
for (const reason of ['stale bus host lock', 'bus host identity mismatch', 'bus host lock does not match host state', 'stale bus writer lock']) {
bus.busStatus = async () => { throw cliError(reason); };
const r = await c.call('POST', STOP, {});
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; mosaic bus recover acme clears a host that died.`);
}
assert.equal(c.calls.filter(x => x[0] === 'busStop').length, 0);
assert.deepEqual(tr.lines(), Array(4).fill({ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'refused', running: null }));
});
test('stop: a stop that is refused, times out or leaves state behind is a visible failure', 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();
const live = async () => ({ org: 'acme', healthy: true, pid: 4242, root: '/data/org-runtime/acme' });
bus.busStatus = live;
bus.busStop = async () => { throw cliError('pid 4242 is not a bus host; refusing to signal it'); };
let r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused');
assert.equal(r.body.message, 'pid 4242 is not a bus host; refusing to signal it. Inspect it with mosaic bus status acme.');
bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
// An unexpected error may come after the signal: the outcome is unknown and the trail says failed.
bus.busStop = async () => { throw new Error('EPERM'); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown');
// The host was signalled and exited, but a lock is left, or the host is still recorded.
let after;
bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; };
after = async () => { throw cliError('stale bus writer lock'); };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'the bus host was stopped, but stale bus writer lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
bus.busStatus = live; after = live;
r = await c.call('POST', STOP, {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./);
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']);
// The host exited on its own between the read and the stop: done, but this press stopped nothing.
bus.busStatus = live;
bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; };
r = await c.call('POST', STOP, {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]);
});
test('stop: an archived organization, or a Console whose bus control has no stop, refuses', 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') });
const bus = await c.admin.bus();
const busStop = bus.busStop; delete bus.busStop;
const n = await c.call('POST', STOP, {});
assert.equal(n.status, 503); assert.equal(n.body.error, 'not-configured');
bus.busStop = busStop;
bus.busStatus = async () => { throw new OrgError('org-archived', 'organization acme is archived'); };
const a = await c.call('POST', STOP, {});
const ra = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.deepEqual([a.status, a.body.error], [422, 'org-archived'], a.raw);
assert.deepEqual([ra.status, ra.body.error], [422, 'org-archived'], ra.raw);
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});