105 lines
9.2 KiB
JavaScript
105 lines
9.2 KiB
JavaScript
// vikunja:19/305 (REQ-PLUG-1): the plug-in switch is a human-only broker action, logged with its
|
|
// actor, and a module that doesn't exist is refused. Same path as the limits verbs.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
const biz = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: [], crossRole: [] } }, cto: { authority: { withinRole: [], crossRole: [] } } } } };
|
|
|
|
test('plugins and plugins.set refuse every non-human session and a forged actor, and pass only the patch with the verified actor', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'plugins-verb-'));
|
|
const store = new Store(root);
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const calls = [];
|
|
const b = new Broker({ store, businesses: biz, organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) });
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
|
|
const reader = b.bindReader({ business: 'demo' });
|
|
const patch = { revision: 'a'.repeat(64), plugins: { 'search-source': 'searxng-url' } };
|
|
for (const cap of [role, reader]) for (const verb of ['plugins', 'plugins.set']) assert.throws(() => b.request(cap, { verb, args: verb === 'plugins' ? {} : patch }), /human-required/);
|
|
assert.equal(calls.length, 0);
|
|
assert.throws(() => b.request(human, { verb: 'plugins.set', args: { ...patch, actor: 'forged' } }), /invalid-request/);
|
|
assert.throws(() => b.request(human, { verb: 'plugins.set', args: { revision: patch.revision } }), /invalid-request/);
|
|
assert.throws(() => b.request(human, { verb: 'plugins', args: { x: 1 } }), /invalid-request/);
|
|
b.request(human, { verb: 'plugins' });
|
|
b.request(human, { verb: 'plugins.set', args: patch });
|
|
assert.deepEqual(calls, [['demo', 'plugins', {}, 'jason'], ['demo', 'plugins.set', patch, 'jason']]);
|
|
});
|
|
|
|
test('runtime: the switch changes the organization file, the receipt carries the actor, and a bad shape, an unknown module and a stale revision are refused with nothing written', async (t) => {
|
|
const { startBroker } = await import('../src/runtime.mjs');
|
|
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'plugins-runtime-'));
|
|
const rolesDir = rolesCopy(root);
|
|
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
|
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
|
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
|
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const file = join(root, 'businesses/demo.json');
|
|
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
|
const view = bus.broker.request(human, { verb: 'plugins' });
|
|
assert.deepEqual(view.plugins, {});
|
|
const done = bus.broker.request(human, { verb: 'plugins.set', args: { revision: view.revision, plugins: { 'search-source': 'searxng-url' } } });
|
|
assert.deepEqual(done.plugins, { 'search-source': 'searxng-url' });
|
|
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).plugins, { 'search-source': 'searxng-url' });
|
|
const last = log().at(-1);
|
|
assert.deepEqual([last.actor, last.state], ['jason', 'committed']);
|
|
assert.deepEqual(last.changes, [{ path: 'plugins', old: null, new: { 'search-source': 'searxng-url' }, oldPresent: false, newPresent: true }]);
|
|
const before = readFileSync(file, 'utf8');
|
|
const entries = log().length;
|
|
const code = (args) => { try { bus.broker.request(human, { verb: 'plugins.set', args }); } catch (e) { return e.code; } return null; };
|
|
assert.equal(code({ revision: done.revision, plugins: { 'search-source': 'no-such-module' } }), 'unknown-plugin');
|
|
assert.equal(code({ revision: done.revision, plugins: { 'search-source': ['searxng-url'] } }), 'invalid-plugins');
|
|
assert.equal(code({ revision: done.revision, plugins: { 'made-up': [] } }), 'invalid-plugins');
|
|
assert.equal(code({ revision: view.revision, plugins: { 'search-source': 'harness-builtin' } }), 'revision-conflict');
|
|
assert.equal(readFileSync(file, 'utf8'), before);
|
|
assert.equal(log().length, entries, 'a refused write logs nothing');
|
|
// A real module switches, and the old value is in the receipt.
|
|
const next = bus.broker.request(human, { verb: 'plugins.set', args: { revision: done.revision, plugins: { 'search-source': 'harness-builtin' } } });
|
|
assert.deepEqual(log().at(-1).changes.map((c) => [c.path, c.old, c.new]), [['plugins.search-source', 'searxng-url', 'harness-builtin']]);
|
|
assert.equal(next.plugins['search-source'], 'harness-builtin');
|
|
});
|
|
|
|
test('vikunja:19/309 through the broker, a patch switches the kinds it names and keeps the rest; null removes one kind and is trailed', async (t) => {
|
|
const { startBroker } = await import('../src/runtime.mjs');
|
|
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'plugins-merge-'));
|
|
const rolesDir = rolesCopy(root);
|
|
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { 'usage-source': ['meter'] } });
|
|
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
|
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
|
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
|
const set = (revision, plugins) => bus.broker.request(human, { verb: 'plugins.set', args: { revision, plugins } });
|
|
let view = bus.broker.request(human, { verb: 'plugins' });
|
|
assert.deepEqual(view.plugins, { 'usage-source': ['meter'] });
|
|
view = set(view.revision, { 'search-source': 'searxng-url' });
|
|
assert.deepEqual(view.plugins, { 'usage-source': ['meter'], 'search-source': 'searxng-url' }, 'usage-source kept');
|
|
assert.deepEqual(log().at(-1).changes.map((c) => c.path), ['plugins.search-source']);
|
|
view = set(view.revision, { 'usage-source': null });
|
|
assert.deepEqual(view.plugins, { 'search-source': 'searxng-url' });
|
|
assert.deepEqual(log().at(-1).changes, [{ path: 'plugins.usage-source', old: ['meter'], new: null, oldPresent: true, newPresent: false }]);
|
|
const code = (plugins) => { try { set(view.revision, plugins); } catch (e) { return e.code; } return null; };
|
|
assert.equal(code({ 'made-up': null }), 'invalid-plugins');
|
|
assert.equal(code({}), 'invalid-plugins');
|
|
assert.equal(code({ 'search-source': 'no-such-module' }), 'unknown-plugin');
|
|
});
|
|
|
|
test('plugins read advertises loadable harnesses even when switched off', async t => {
|
|
const {startBroker}=await import('../src/runtime.mjs');
|
|
const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../organization/tests/helpers.mjs');
|
|
const root=mkdtempSync(join(tmpdir(),'plugins-available-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
const rolesDir=rolesCopy(root);const doc=businessDoc(root,'demo');doc.plugins={harness:['claude-code']};
|
|
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
|
const host=await startBroker({dataRoot:join(root,'data'),businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>host.close());
|
|
const human=host.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
|
const view=host.broker.request(human,{verb:'plugins'});
|
|
assert.deepEqual(view.plugins.harness,['claude-code']);assert.ok(view.available.harness.includes('pi'));assert.ok(view.available.harness.includes('claude-code'));
|
|
});
|