// 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 '); 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 '); 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 /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 /org-runtime/acme/host/host.log. The bus host said: no notifier config at /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 --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 /orgs/acme/host.log'); assert.equal(plainMessage(ctx, 'cannot read /etc/passwd'), 'cannot read '); }); 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, /