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.
884 lines
65 KiB
JavaScript
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(), []);
|
|
});
|