- template-save: one alternation pass replaces every literal, so an
instance named root, org or agent never rewrites a placeholder already
written in. checkPack's refusal message is passed on as the reason. A
write or fsync failure removes the partial file it created.
- broker #templateFail drops a reason the credential check refuses.
- cli template words: humanTransport throws a CliError named by the bus
code; a known code now gains its hint (the test transport throws the
same CliError).
- template-http: the header notes serve.mjs strips /api/orgs/<id>.
- pins: the two .console verbs in console-decide and request-console;
the packages/business/src re-export for template-save.mjs.
- tests: placeholder collision, a held-value key masked as [key], and a
save after a real pack.apply.console keeping {id, version}.
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 === 'invalid-request' && e.reason === 'a template of this name is already saved');
|
|
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));
|
|
});
|