// 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')); });