P3-1: releasing a console start lock marks it released through its own descriptor and removes the path only while it is still that file; a released lock put back by a racing clear is cleared by the next start or stop. P3-2: org definitions reports unknown-organization only when the organization file itself is missing; another missing file names the operation. P3-3: the path cut also covers a path after ':' or inside backticks.
191 lines
14 KiB
JavaScript
191 lines
14 KiB
JavaScript
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { join } from 'node:path';
|
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { createLauncher } from '../src/launcher.mjs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { orgCommand } from '../src/org.mjs';
|
|
import { bootConfig, loadSystem } from '../src/config.mjs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { startBroker } from '../../bus/src/runtime.mjs';
|
|
import { initializeControl } from '../../processing-control/src/state.mjs';
|
|
import { launchLogFile } from '../../seat/src/session.mjs';
|
|
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
|
|
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
|
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
|
|
|
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
|
|
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
|
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
|
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
|
if (x.op === 'mayLaunch') return { allow: true };
|
|
if (x.op === 'authorizeLaunch') throw stop();
|
|
if (x.op === 'definitionScan') return { held: null };
|
|
return {};
|
|
} };
|
|
|
|
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
|
|
const root = tmp(t);
|
|
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
|
|
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
|
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
|
const file = join(root, 'config/businesses/acme.json');
|
|
withoutHostCredentials(f.env, { piKey: true });
|
|
initializeControl(f.dataRoot);
|
|
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
|
|
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
|
|
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
|
|
|
|
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
|
|
// launch and the chief's launch both refuse.
|
|
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
|
|
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
|
|
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
|
|
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
|
assert.equal(event.code, 'organization-refused');
|
|
assert.equal(event.instance, 'chief');
|
|
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
|
|
await launcher.close?.();
|
|
|
|
// A host does not start on a file that names a missing definition.
|
|
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
|
|
});
|
|
|
|
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
|
|
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
|
function files(dir) {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
|
}
|
|
|
|
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
|
|
const root = tmp(t);
|
|
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
|
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
|
const file = join(root, 'config/businesses/acme.json');
|
|
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
|
|
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
|
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
|
t.after(() => b.close());
|
|
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
|
|
const calls = [];
|
|
// The human transport's shape: a bus refusal arrives as its code.
|
|
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
|
|
calls.push(verb);
|
|
try { return structuredClone(b.broker.request(human, { verb, args })); }
|
|
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
|
|
} };
|
|
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
|
|
const hash = templateHash(f.roles, 'coder');
|
|
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
|
|
|
|
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
|
|
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
|
|
const spec = join(root, 'scout.json');
|
|
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
|
|
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
|
|
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
|
|
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
|
|
assert.equal(edited.verb, 'edit');
|
|
assert.equal(edited.rev, 2);
|
|
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
|
|
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
|
|
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
|
|
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
|
|
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
|
|
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
|
|
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
|
|
|
|
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
|
|
// the token reaches no message, no organization file and nothing the bus wrote.
|
|
const before = readFileSync(file);
|
|
const beforeChanges = readFileSync(changes);
|
|
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
|
|
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
|
|
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
|
|
}
|
|
assert.deepEqual(readFileSync(file), before);
|
|
assert.deepEqual(readFileSync(changes), beforeChanges);
|
|
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
|
|
|
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
|
|
// called; a stale --if-rev and an in-use definition refuse with what they mean.
|
|
writeFileSync(spec, `{"role": ${SENTINEL}`);
|
|
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
|
|
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
|
|
const asked = calls.length;
|
|
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
|
|
assert.equal(calls.length, asked);
|
|
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
|
|
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
|
|
assert.deepEqual(readFileSync(file), before);
|
|
|
|
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
|
|
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
|
|
const sent = calls.length;
|
|
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
|
|
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
|
|
}
|
|
assert.equal(calls.length, sent);
|
|
assert.deepEqual(readFileSync(file), before);
|
|
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
|
|
|
|
// With no bus call to make, a write refuses rather than writing without the secret check.
|
|
const x = io(f.env);
|
|
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
|
|
assert.deepEqual(readFileSync(file), before);
|
|
});
|
|
|
|
// E2E6 fix 2: an unknown definition, an unknown organization, a bad id and a bad name each end in
|
|
// one usage or refusal line: no stack trace and no host path.
|
|
test('E2E6 mosaic org definitions: unknown names, an unknown org and a bad id give one line, no stack and no path', async (t) => {
|
|
const root = tmp(t);
|
|
const f = fixture(root, 'acme');
|
|
const { run } = await import('../src/cli.mjs');
|
|
const cases = [
|
|
[['acme', 'show', 'ghost'], 3, 'mosaic: definition-missing: definitions declares no ghost; list them with mosaic org definitions acme list'],
|
|
[['acme', 'diff', 'ghost'], 3, 'mosaic: definition-missing: definitions declares no ghost; list them with mosaic org definitions acme list'],
|
|
[['acme', 'show', 'Bad Name'], 3, 'mosaic: definition-missing: definitions declares no definition by that name; list them with mosaic org definitions acme list'],
|
|
[['nosuch', 'list'], 3, 'mosaic: unknown-organization: this instance has no organization nosuch; list them with mosaic org list'],
|
|
[['nosuch', 'show', 'x'], 3, 'mosaic: unknown-organization: this instance has no organization nosuch; list them with mosaic org list'],
|
|
];
|
|
for (const [args, exit, line] of cases) {
|
|
const x = io(f.env);
|
|
assert.equal(await run(['org', 'definitions', ...args], x, {}), exit, args.join(' '));
|
|
assert.equal(x.err.text, `${line}\n`, args.join(' '));
|
|
assert.ok(!x.err.text.includes(root) && !/\n\s+at /.test(x.err.text), args.join(' '));
|
|
}
|
|
for (const args of [['Bad/Id', 'list'], ['../x', 'show', 'y']]) {
|
|
const x = io(f.env);
|
|
assert.equal(await run(['org', 'definitions', ...args], x, {}), 4, args.join(' '));
|
|
assert.match(x.err.text, /^mosaic: invalid organization id: an id is 1 to 64 of a-z/);
|
|
assert.match(x.err.text, /\nmosaic org definitions templates \| definitions ID list/);
|
|
assert.ok(!x.err.text.includes(root) && !/\n\s+at /.test(x.err.text));
|
|
}
|
|
});
|
|
|
|
test('E2E6 definitionsFailure: a raw filesystem error is named by its operation and code, a message path is cut, an unknown error says only that it failed', async () => {
|
|
const { definitionsFailure } = await import('../src/org-definitions.mjs');
|
|
const fsError = (code, syscall) => Object.assign(new Error(`${code}: no such file, open '/home/someone/.config/x/acme.json'`), { code, syscall, path: '/home/someone/.config/x/acme.json' });
|
|
let e = definitionsFailure(fsError('EACCES', 'open'), 'acme');
|
|
assert.deepEqual([e.code, e.exitCode, e.message], ['organization-unreadable', 3, 'organization-unreadable: cannot open the file of organization acme (EACCES)']);
|
|
e = definitionsFailure(Object.assign(new Error("organization file /srv/x/acme.json is not valid"), { code: 'organization-invalid' }), 'acme');
|
|
assert.equal(e.message, 'organization-invalid: organization file <path> is not valid');
|
|
e = definitionsFailure(new TypeError('Cannot read properties of undefined'), 'acme');
|
|
assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-failed', 1, 'definitions-failed: the definitions command failed']);
|
|
// f6c review P3-2: only the organization file missing means no organization.
|
|
e = definitionsFailure(fsError('ENOENT', 'open'), 'acme');
|
|
assert.deepEqual([e.code, e.message], ['unknown-organization', 'unknown-organization: this instance has no organization acme; list them with mosaic org list']);
|
|
const rolesGone = Object.assign(new Error("ENOENT: no such file or directory, scandir '/home/someone/roles'"), { code: 'ENOENT', syscall: 'scandir', path: '/home/someone/roles' });
|
|
e = definitionsFailure(rolesGone, 'templates');
|
|
assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-unreadable', 3, 'definitions-unreadable: cannot scandir a file the definitions command reads (ENOENT)']);
|
|
e = definitionsFailure(rolesGone, 'acme');
|
|
assert.equal(e.code, 'organization-unreadable');
|
|
// f6c review P3-3: a path after ':' or inside backticks is cut.
|
|
e = definitionsFailure(Object.assign(new Error('organization file:/srv/x/acme.json is not valid'), { code: 'organization-invalid' }), 'acme');
|
|
assert.equal(e.message, 'organization-invalid: organization file:<path> is not valid');
|
|
e = definitionsFailure(Object.assign(new Error('bad role at `/srv/roles/x.json`'), { code: 'organization-invalid' }), 'acme');
|
|
assert.equal(e.message, 'organization-invalid: bad role at `<path>`');
|
|
});
|