mosaic org config validate|resolve runs runConfigCommand (packages/organization/src/config-command.mjs), the callable service the old mosaic business validate|resolve executable now calls too. The service returns the exit code, never exits the process and does nothing on import. The spelling sets only the stderr prefix and the usage text; status and stdout are byte-identical, and the v1 output keeps the business key. mosaic org instances|instance|veto takes the same arguments as mosaic business instances|instance|veto and runs the same proof-bearing instances command. It is dispatched before orgCommand, so it never builds the dev-local-owner context. mosaic org validate FILE is unchanged. Help, the UI labels for agent actions and instance reasons, and the current docs now use the org spelling and the packages/organization paths. Two webui test regexes follow the label text. packages/cli/tests/ rename-b-commands.test.mjs compares the two spellings golden for golden on status, stdout, stderr and authorization outcomes, and covers a draft file named like an installed ID.
172 lines
12 KiB
JavaScript
172 lines
12 KiB
JavaScript
// Row 165 (#1662, Q171, REQ-ROLE-3): the Console's role instance read and write. The write has the
|
|
// Settings boundary and makes one human-transport call, `instances.set`; the broker's and the
|
|
// writer's own checks are in the bus and business tests. The first test runs a real broker over a
|
|
// temporary organization directory, so the add reaches the file, the log and `validate`. Row 194
|
|
// (#1692, D3): that broker boots from the file through bootConfig, so the add goes live with no restart.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { startBroker } from '../../bus/src/runtime.mjs';
|
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
|
|
const BUSINESS_CLI = fileURLToPath(new URL('../../organization/src/cli.mjs', import.meta.url));
|
|
const REV = 'a'.repeat(64);
|
|
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra });
|
|
|
|
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) {
|
|
const calls = [], lines = [];
|
|
const auth = await createSsoSession(t, { host: 'console.example' });
|
|
const server = await startServer({
|
|
port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
|
instancesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
|
|
});
|
|
t.after(() => new Promise(r => server.close(r)));
|
|
const session = await auth.signIn(server.address().port);
|
|
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
|
|
}
|
|
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
|
|
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
|
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
|
});
|
|
|
|
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0, the log names the human and no restart is needed (row 194)', async t => {
|
|
const root = tmp(t, 'webui-instances-');
|
|
const f = fixture(root, 'acme');
|
|
const dir = join(root, 'config');
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
|
const host = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
|
t.after(() => host.close());
|
|
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
// The human transport's shape: an answer, or a BusReadError with the broker's code.
|
|
const call = async (verb, args) => {
|
|
try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) {
|
|
if (e instanceof BusError) throw new BusReadError(e.code);
|
|
throw e;
|
|
}
|
|
};
|
|
const s = await setup(t, { bus: { instances: () => call('instances') }, write: args => call('instances.set', args) });
|
|
const file = join(dir, 'businesses', 'acme.json');
|
|
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
|
const read = await send(s, '/api/bus/instances', undefined, s.headers, 'GET');
|
|
assert.equal(read.status, 200);
|
|
const [current] = read.body.rows;
|
|
assert.equal(current.definitions['security-reviewer'].accepted, true);
|
|
assert.deepEqual(current.definitions.reviewer, { services: ['gitea', 'vikunja'], accepted: false });
|
|
|
|
const added = await send(s, '/api/instances', { revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
|
assert.equal(added.status, 200, JSON.stringify(added.body));
|
|
assert.equal(added.body.restart, false);
|
|
assert.equal(added.body.instances['sec-1'].launched, true);
|
|
assert.deepEqual(s.calls, [{ revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }]);
|
|
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=false$/);
|
|
const entry = log().at(-1);
|
|
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', added.body.transaction]);
|
|
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
|
|
const checked = spawnSync(process.execPath, [BUSINESS_CLI, 'validate', 'acme'], {
|
|
env: { PATH: process.env.PATH, HOME: join(root, 'home'), MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles }, encoding: 'utf8',
|
|
});
|
|
assert.equal(checked.status, 0, checked.stderr);
|
|
|
|
// A vikunja definition, which needs a bot the Console doesn't take (sage-1610 a), a definition change on an instance holding them and a
|
|
// stale revision: each refused with its code and why, nothing written, nothing logged.
|
|
const before = readFileSync(file, 'utf8'), entries = log().length;
|
|
for (const [args, status, code, why] of [
|
|
[{ revision: added.body.revision, instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 400, 'organization-tracker-required', /uses vikunja and needs a Vikunja bot\. The Console names no bot; use mosaic org instance add with --bot and --bot-id/],
|
|
[{ revision: added.body.revision, instance: 'coder', op: 'change', definition: 'security-reviewer' }, 400, 'organization-credentials-out-of-scope', /hand edit of the business file/],
|
|
[{ revision: current.revision, instance: 'gate-1', op: 'add', definition: 'merge-gate' }, 409, 'revision-conflict', /changed since you read it/],
|
|
[{ revision: added.body.revision, instance: 'asst-2', op: 'add', definition: 'assistant' }, 400, 'organization-definition-refused', /^organization-definition-refused: this form neither adds nor changes an assistant, and moves no instance to the assistant definition;/],
|
|
]) {
|
|
const r = await send(s, '/api/instances', args);
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], JSON.stringify(args));
|
|
assert.match(r.body.message, why);
|
|
assert.match(r.body.message, /Nothing changed\.$/);
|
|
}
|
|
assert.equal(readFileSync(file, 'utf8'), before);
|
|
assert.equal(log().length, entries);
|
|
});
|
|
|
|
test('row 165 Console: the instance write needs send on, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
|
const data = { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' };
|
|
const off = await setup(t, { on: false });
|
|
assert.equal((await send(off, '/api/instances', data)).status, 403);
|
|
// A writer without send on still refuses, so the route itself checks send.
|
|
const unsent = await setup(t, { on: false, writer: true });
|
|
assert.equal((await send(unsent, '/api/instances', data)).status, 403);
|
|
assert.equal(unsent.calls.length, 0);
|
|
const s = await setup(t);
|
|
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
|
const hs = { ...s.headers };
|
|
if (value === undefined) delete hs[key]; else hs[key] = value;
|
|
assert.equal((await send(s, '/api/instances', data, hs)).status, status, key);
|
|
}
|
|
assert.equal((await send(s, '/api/instances', undefined, s.headers, 'GET')).status, 405);
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('row 165 Console: the instance write takes JSON within 16 KiB and only its own keys; holder, credentials and an actor are refused', async t => {
|
|
const s = await setup(t);
|
|
const base = { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' };
|
|
for (const value of [
|
|
{ ...base, actor: 'mos' }, { ...base, holder: 'mos' }, { ...base, credentials: {} }, { ...base, tracker: {} },
|
|
{ ...base, revision: 'stale' }, { ...base, op: 'remove' }, { ...base, instance: 7 }, { ...base, definition: 7 },
|
|
{ ...base, vars: [] }, { ...base, vars: null }, { ...base, launch: 'yes' }, [], 'bad', { ...base, vars: { pad: 'x'.repeat(17000) } },
|
|
]) assert.equal((await send(s, '/api/instances', value)).status, 400, JSON.stringify(value).slice(0, 60));
|
|
assert.equal((await send(s, '/api/instances', base, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
|
|
assert.equal(s.calls.length, 0);
|
|
assert.ok(s.lines.every(l => !l.includes('17000') && !l.includes('xxxx')), 'vars are never logged');
|
|
});
|
|
|
|
test('row 165 Console: a bus refusal says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
|
const fail = code => async () => { throw new BusReadError(code); };
|
|
for (const [write, status, error, sent] of [
|
|
[fail('human-required'), 403, 'human-required', false], [fail('organization-instance-exists'), 409, 'organization-instance-exists', false],
|
|
[fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false], [fail('invalid-request'), 400, 'invalid-request', false],
|
|
[fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'],
|
|
[async () => ({ revision: REV, instances: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
// mos-0825 F1: the page draws the answer as its next view, so one without definitions is odd.
|
|
[async () => ({ revision: REV, restart: true, instances: { 'gate-1': {} } }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => ({ revision: REV, restart: true, instances: { 'gate-1': {} }, definitions: [] }), 503, 'outcome-unknown', 'unknown'],
|
|
// Dewey F2: each other part of the shape wrong alone.
|
|
[async () => ({ revision: 'x', restart: true, instances: { 'gate-1': {} }, definitions: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => ({ revision: REV, restart: 'yes', instances: { 'gate-1': {} }, definitions: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => ({ revision: REV, restart: true, instances: {}, definitions: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
]) {
|
|
const s = await setup(t, { write });
|
|
const r = await send(s, '/api/instances', { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent]);
|
|
assert.equal(s.calls.length, 1);
|
|
assert.match(s.lines[0], new RegExp(` instance user="jason" instance="gate-1" error=${error}$`));
|
|
}
|
|
});
|
|
|
|
test('row 165 Console: the instances read takes no arguments, needs a bus and refuses an answer without a revision', async t => {
|
|
const get = (s, path) => send(s, path, undefined, s.headers, 'GET');
|
|
const none = await setup(t);
|
|
assert.equal((await get(none, '/api/bus/instances')).status, 503);
|
|
const s = await setup(t, { bus: { instances: async () => view() } });
|
|
assert.equal((await get(s, '/api/bus/instances?x=1')).status, 400);
|
|
assert.deepEqual((await get(s, '/api/bus/instances')).body.rows, [view()]);
|
|
const odd = await setup(t, { bus: { instances: async () => ({ ...view(), revision: 'x' }) } });
|
|
assert.equal((await get(odd, '/api/bus/instances')).status, 502);
|
|
// Dewey F2 note: an answer without instances or without definitions is refused the same way.
|
|
for (const drop of ['instances', 'definitions']) {
|
|
const { [drop]: _, ...rest } = view();
|
|
const partial = await setup(t, { bus: { instances: async () => rest } });
|
|
assert.equal((await get(partial, '/api/bus/instances')).status, 502, drop);
|
|
}
|
|
const refused = await setup(t, { bus: { instances: async () => { throw new BusReadError('human-required'); } } });
|
|
assert.equal((await get(refused, '/api/bus/instances')).status, 403);
|
|
});
|