1. Stale bus host lock refusals from bus status, start, stop and restart name mosaic bus recover ORG. 2. org definitions: unknown org, unknown or malformed name and bad id give one refusal or usage line, no stack trace, no host path. 3. Pack, board and workflow refusals print their hint as template save does; tested with the real CliError transport shape. 4. console start and stop clear a console/start.lock whose named supervisor (pid and start time) is gone; a live or unnamed holder refuses. 5. Template save keeps free text verbatim and writes no placeholders; the preview lists each field that mentions an org-specific name. 6. A request the shim never sent (no socket, connect refused) exits 5 and the CLI reports bus-not-running with the start and recover line. 7. autonomy-precondition carries the check that failed, including on the hire paths. 8. Saving under an existing template name refuses template-exists with a next step; refusal table entry added, channel-proof pins moved.
179 lines
12 KiB
JavaScript
179 lines
12 KiB
JavaScript
// #1873 SP8: saving a template at the bus. Only the organization's owner saves or lists: the CLI's
|
|
// human cap on the unsuffixed names, the owner's console cap on the .console ones. The saved file
|
|
// applies through SP4 as saved:<name> for its saver.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { CONSOLE_VERBS, TEMPLATE_BUS_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
|
|
import { REFUSALS } from '../src/refusals.mjs';
|
|
import { VERB_RIGHTS } from '../../organization/src/org-rights.mjs';
|
|
|
|
const TOKEN = 'fixture-held-token-sp8-bus-3a9d';
|
|
const SUBJECT = { provider: 'test', iss: 'test', sub: 'owner' };
|
|
|
|
async function harness(t, { mutate = () => {} } = {}) {
|
|
const { startBroker } = await import('../src/runtime.mjs');
|
|
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
|
|
const { entryFromTemplate, readTemplate } = await import('../../organization/src/definitions.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'tmpl-'));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = businessDoc(root, 'demo');
|
|
doc.plugins = { harness: ['pi'] };
|
|
const writer = entryFromTemplate(readTemplate(rolesDir, 'coder'));
|
|
writer.instructions = 'Write code for demo. Ask jason.';
|
|
doc.definitions = { writer };
|
|
// An instance of the org definition, with its own token files and tracker bot.
|
|
doc.roles.ana = { ...structuredClone(doc.roles.coder), definition: 'org:writer', tracker: { bot: 'bot-demo-ana', botId: 11 } };
|
|
mutate(doc, root);
|
|
writeJson(join(root, 'businesses/demo.json'), doc);
|
|
const data = join(root, 'data');
|
|
mkdirSync(data, { mode: 0o700 });
|
|
const token = join(root, 'held.token');
|
|
writeFileSync(token, TOKEN, { mode: 0o600 });
|
|
chmodSync(token, 0o600);
|
|
const roles = { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
|
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } } };
|
|
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles } };
|
|
const bus = await startBroker({ dataRoot: data, businesses, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
|
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const b = bus.broker;
|
|
const consoleOwner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const call = async (verb, args = {}, cap = consoleOwner) => b.request(cap, { verb, args });
|
|
const path = join(root, 'businesses/demo.json');
|
|
const saved = (name, account = 'jason') => join(root, 'templates', account, `${name}.json`);
|
|
return { bus, b, root, rolesDir, path, consoleOwner, human, call, saved };
|
|
}
|
|
|
|
// Preview, then save with the preview's digest: the console names by default, the cli names for cap.
|
|
const save = async (call, name, extra = {}, cap) => {
|
|
const verb = cap ? 'template.save' : 'template.save.console';
|
|
const preview = await call(verb, { name, preview: true, ...extra }, cap);
|
|
return { preview, out: await call(verb, { name, digest: preview.digest, ...extra }, cap) };
|
|
};
|
|
|
|
test('SP8 red before: the owner saves an organization whose instance holds credentials; neither credentials nor copy material reach the file', async t => {
|
|
const { call, saved, root } = await harness(t);
|
|
const { preview, out } = await save(call, 'demo-shape');
|
|
assert.deepEqual(out, { name: 'demo-shape', scope: 'org', digest: preview.digest });
|
|
const text = readFileSync(saved('demo-shape'), 'utf8');
|
|
assert.equal(text, preview.json);
|
|
for (const k of ['copyBindings', 'copyId', 'orgIncarnation', 'generation', 'tracker', 'holder']) assert.ok(!text.includes(`"${k}"`), k);
|
|
assert.ok(!text.includes(join(root, 'secrets')), 'a token file path reached the template');
|
|
assert.ok(!text.includes('"file"'));
|
|
// The role's credential request lines are kept.
|
|
assert.ok(JSON.parse(text).definitions.writer.role.credentials.length > 0);
|
|
});
|
|
|
|
test('SP8 a saved template applies through SP4 as saved:<name> for its saver', async t => {
|
|
const { call, path } = await harness(t);
|
|
await save(call, 'demo-shape');
|
|
const listed = await call('template.list.console');
|
|
assert.deepEqual(listed.templates.map(x => [x.name, x.scope]), [['demo-shape', 'org']]);
|
|
const preview = await call('pack.preview.console', { pack: 'saved:demo-shape' });
|
|
assert.equal(preview.pack.origin, 'saved');
|
|
assert.ok(preview.hires.some(h => h.name === 'coder-1'), JSON.stringify(preview.hires.map(h => h.name)));
|
|
const before = JSON.parse(readFileSync(path, 'utf8'));
|
|
const out = await call('pack.apply.console', { pack: 'saved:demo-shape', digest: preview.digest });
|
|
const after = JSON.parse(readFileSync(path, 'utf8'));
|
|
assert.equal(after.packs.length, (before.packs ?? []).length + 1);
|
|
assert.equal(after.packs.at(-1).origin, 'saved');
|
|
assert.equal(after.packs.at(-1).id, 'demo-shape');
|
|
// writer is already the organization's own (same); the template references the coder template.
|
|
assert.ok(out.added.includes('definitions.coder'), JSON.stringify(out.added));
|
|
assert.equal(preview.hires.find(h => h.name === 'coder-1').definition, 'writer');
|
|
});
|
|
|
|
test('SP8 the owner\'s CLI cap saves on the unsuffixed name and writes the file', async t => {
|
|
const { call, human, saved } = await harness(t);
|
|
const { out } = await save(call, 'cli-shape', {}, human);
|
|
assert.equal(out.name, 'cli-shape');
|
|
assert.ok(existsSync(saved('cli-shape')));
|
|
assert.deepEqual((await call('template.list', {}, human)).templates.map(x => x.name), ['cli-shape']);
|
|
});
|
|
|
|
test('SP8 who may call: a member\'s session, an agent and the tester refuse; the owner\'s console cap cannot call the cli name', async t => {
|
|
const { b, call, human, root } = await harness(t);
|
|
assert.deepEqual(TESTER_ORGANIZATION_VERBS, ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
|
const member = b.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read', 'org.setup'] });
|
|
for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(member, { verb, args: { name: 'x', preview: true } }), /human-required/);
|
|
for (const verb of ['template.list', 'template.list.console']) assert.throws(() => b.request(member, { verb, args: {} }), /human-required/);
|
|
const tester = b.bindTester({ business: 'demo' });
|
|
assert.throws(() => b.request(tester, { verb: 'template.save', args: { name: 'x', preview: true } }), /tester-refused/);
|
|
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
|
b.request(agent, { verb: 'role.claim' });
|
|
for (const verb of ['template.save', 'template.save.console']) assert.throws(() => b.request(agent, { verb, args: { name: 'x', preview: true } }), /human-required/);
|
|
await assert.rejects(call('template.save', { name: 'x', preview: true }), /human-required/);
|
|
await assert.rejects(call('template.save.console', { name: 'x', preview: true }, human), /human-required/);
|
|
assert.ok(!existsSync(join(root, 'templates')));
|
|
});
|
|
|
|
test('SP8 the real credential store: a held value in kept text refuses template-secret-suspected with the field path as its reason', async t => {
|
|
// A definition holding one never loads (definition-holds-credential); workflow text is what save
|
|
// must catch itself.
|
|
const workflow = { description: `Ship it, key ${TOKEN}`, steps: [{ title: 'Write', state: 'todo', roles: ['writer'] }] };
|
|
const { call, root } = await harness(t, { mutate: doc => { doc.workflows = { change: workflow }; } });
|
|
await assert.rejects(call('template.save.console', { name: 'leaky', preview: true }), (e) => {
|
|
assert.equal(e.code, 'template-secret-suspected');
|
|
assert.equal(e.reason, 'workflows.change.description');
|
|
assert.ok(!String(e.message).includes(TOKEN) && !JSON.stringify(e).includes(TOKEN));
|
|
return true;
|
|
});
|
|
assert.ok(!existsSync(join(root, 'templates')));
|
|
});
|
|
|
|
test('SP8 a key that holds a held value: the refusal reason masks it as [key], never the value', async t => {
|
|
// A definition keyed by one never loads (definition-holds-credential); a workflow keyed by one is
|
|
// what save sees. The broker also drops any reason its credential check refuses.
|
|
const workflow = { description: 'Ship it.', steps: [{ title: 'Write', state: 'todo', roles: ['writer'] }] };
|
|
const { call, root } = await harness(t, { mutate: doc => { doc.workflows = { [TOKEN]: workflow }; } });
|
|
await assert.rejects(call('template.save.console', { name: 'leaky', preview: true }), (e) => {
|
|
assert.equal(e.code, 'template-secret-suspected');
|
|
assert.equal(e.reason, 'workflows[key]');
|
|
assert.ok(!String(e.message).includes(TOKEN) && !JSON.stringify(e).includes(TOKEN));
|
|
return true;
|
|
});
|
|
assert.ok(!existsSync(join(root, 'templates')));
|
|
});
|
|
|
|
test('SP8 a save after a real pack.apply.console keeps each packs entry as {id, version}', async t => {
|
|
const { call, saved } = await harness(t);
|
|
const packs = await call('packs.console');
|
|
const shipped = packs.packs.find(p => p.origin === 'shipped');
|
|
assert.ok(shipped, JSON.stringify(packs.packs.map(p => [p.id, p.origin])));
|
|
const pack = await call('pack.preview.console', { pack: shipped.id });
|
|
await call('pack.apply.console', { pack: shipped.id, digest: pack.digest });
|
|
await save(call, 'after-apply');
|
|
const kept = JSON.parse(readFileSync(saved('after-apply'), 'utf8')).packs;
|
|
assert.deepEqual(kept, [{ id: shipped.id, version: pack.pack.version }]);
|
|
});
|
|
|
|
test('SP8 argument checks: a second save, a .. name, an unknown key, preview with a digest, and a stale digest', async t => {
|
|
const { call, saved } = await harness(t);
|
|
const { preview } = await save(call, 'demo-shape');
|
|
const first = readFileSync(saved('demo-shape'));
|
|
await assert.rejects(call('template.save.console', { name: 'demo-shape', digest: preview.digest }), (e) => e.code === 'template-exists' && e.reason === 'a template of this name is already saved; save under another name');
|
|
assert.deepEqual(readFileSync(saved('demo-shape')), first);
|
|
for (const name of ['..', '../x', 'a/b']) await assert.rejects(call('template.save.console', { name, preview: true }), { code: 'invalid-request' });
|
|
for (const args of [{ name: 'x', preview: true, account: 'alex' }, { name: 'x', preview: true, digest: preview.digest }, { name: 'x' }, { preview: true }, { name: 'x', preview: false }])
|
|
await assert.rejects(call('template.save.console', args), { code: 'invalid-request' });
|
|
await assert.rejects(call('template.list.console', { account: 'alex' }), { code: 'invalid-request' });
|
|
await assert.rejects(call('template.save.console', { name: 'other', digest: preview.digest }), { code: 'pack-plan-stale' });
|
|
});
|
|
|
|
test('SP8 the schema pins: both .console verbs are console verbs, owner-only in VERB_RIGHTS, and template-secret-suspected has a rail', () => {
|
|
for (const verb of ['template.save.console', 'template.list.console']) {
|
|
assert.ok(CONSOLE_VERBS.includes(verb), verb);
|
|
assert.ok(Object.hasOwn(VERB_RIGHTS, verb) && VERB_RIGHTS[verb] === null, verb);
|
|
assert.ok(Object.hasOwn(TEMPLATE_BUS_KEYS, verb));
|
|
}
|
|
assert.ok(!CONSOLE_VERBS.includes('template.save') && !CONSOLE_VERBS.includes('template.list'));
|
|
const c = 'template-secret-suspected';
|
|
assert.ok(Object.hasOwn(REFUSALS, c));
|
|
assert.ok(REFUSALS[c].rail?.length > 5);
|
|
assert.ok(REFUSALS[c].nextStep?.length > 20);
|
|
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
|
|
});
|