Conflicts kept both sides: rename stage B organization paths and the relative-launch re-exec with F3 imports, F2 onboarding script and the north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE, and the /api/config fields from both.
568 lines
42 KiB
JavaScript
568 lines
42 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' } });
|
|
});
|
|
|
|
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);
|
|
});
|