`mosaic business instances BUSINESS` reads the role instances, and `mosaic business instance add|set` writes one add or change through the broker's new human-only `instances` and `instances.set` verbs. The writer is packages/business/src/instance-settings.mjs. It takes the caller's revision, validates the next document, refuses a definition that declares credentials (organization-credentials-out-of-scope), keeps protected instances as they are, logs the human as the actor, and says when the running bus needs a restart. Neither command restarts the bus. The writer refuses the instance ids human and tester with invalid-request, because the broker refuses a business whose roles use either name when the bus starts. The Console's Settings view shows the instances and, under --send only, posts to POST /api/instances with the exact Origin and same-origin Fetch Metadata. A refusal keeps the draft. An answer without the view's shape is outcome-unknown (503). The page's public/ diff carries the decision 123 go in mos-0832. Neither verb is in ORGANIZATION_KEYS, so no agent tool reaches them. Owner Darkwing, brief in row 165 (#1662). Round 3 rebases round 2 onto4f45cadc0(row 156). The public/ +/- digest is 939ecd28...9444, unchanged at landing. Reviewers on candidate manifest e30b54da...f580: Dewey approve 29052, Filbert approve 29053. Seat run on4f45cadc0: 1436/0 singles, 37/37 mutants killed. Sage gate one8b09735a: GATE_PASSED 25/25, packages 2282/0, queue node 156/0, script selftests 422/0, 23:59:13Z to 00:05:35Z. Rebased onto50a57e454over two SESSIONS-only commits. Nothing live.
174 lines
12 KiB
JavaScript
174 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`.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdirSync, 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 { systemFor } from '../../business/tests/helpers.mjs';
|
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
|
|
const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url));
|
|
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
|
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
|
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: true }), bus = null } = {}) {
|
|
const calls = [], lines = [];
|
|
const server = await startServer({
|
|
port: 0, preview, 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)));
|
|
return { calls, lines, port: server.address().port };
|
|
}
|
|
const send = (s, path, data, hs = 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 and the log names the human', async t => {
|
|
const root = tmp(t, 'webui-instances-');
|
|
const f = fixture(root, 'acme');
|
|
mkdirSync(f.dataRoot, { mode: 0o700 });
|
|
const dir = join(root, 'config');
|
|
const host = await startBroker({
|
|
dataRoot: f.dataRoot,
|
|
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
|
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
|
});
|
|
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, { host: 'console.example', 'x-user': 'jason' }, '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, true);
|
|
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=true$/);
|
|
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 definition that declares credentials, 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-credentials-out-of-scope', /declares credentials, or the instance holds them\. The Console binds no credentials/],
|
|
[{ 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], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
|
const hs = { ...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, 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, { ...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, { host: 'console.example', 'x-user': 'jason' }, '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);
|
|
});
|