import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Broker } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; function fixture(t, cross = false) { const root = mkdtempSync(join(tmpdir(), 'bus-citation-')), store = new Store(root); const role = (withinRole = [], crossRole = []) => ({ authority: { withinRole, crossRole } }); const business = (id) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: role(cross ? [] : ['message.send'], cross ? ['message.send'] : []), cto: role(), coder: role(), }, }); const b = new Broker({ store, businesses: { demo: business('demo'), other: business('other') } }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); const agent = (role, business = 'demo') => { const cap = b.bindLaunch({ business, role, run: business + role, harness: 'pi', address: role }); call(cap, 'role.claim'); return cap; }; const pm = agent('pm'), human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const raise = (cap = pm, action = 'role.launch', target = 'coder') => call(cap, 'decision.raise', { action, target, domain: 'technical', question: 'Launch?', options: [ { key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }, ], recommendation: 'yes', blocking: true, task_ref: 'vikunja:3/44', }); const send = (id, body = 'Blocking: launch?') => call(pm, 'message.send', { to: 'human', class: 'DECISION', decision: id, body }); return { b, store, call, agent, pm, human, raise, send }; } test('within-role sends cite an open gated launch decision without spending it or naming it in grants', (t) => { const f = fixture(t), d = f.raise(); assert.equal(d.class, 'gated'); const first = f.send(d.id), second = f.send(d.id); for (const id of [first.id, second.id]) assert.equal(f.store.get('SELECT decision FROM messages WHERE id=?', id).decision, d.id); const events = f.store.all( "SELECT subject,body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send'", ); assert.equal(events.length, 2); for (const e of events) { assert.equal(e.subject, 'human'); assert.equal(Object.hasOwn(JSON.parse(e.body), 'decision'), false); } f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' }); assert.equal(f.b.authorize(f.pm, 'role.launch', { decision: d.id, target: 'coder' }).decision, d.id); assert.throws( () => f.b.authorize(f.pm, 'role.launch', { decision: d.id, target: 'coder' }), /decision-consumed/, ); // A citation is still valid after consumption: it grants no authority. f.send(d.id, 'The launch was authorized.'); }); test('missing and foreign-business citations refuse and roll back message and grant', (t) => { const f = fixture(t), foreign = f.raise(f.agent('pm', 'other')); for (const id of ['missing-decision', foreign.id]) assert.throws(() => f.send(id), /decision-not-found/); assert.equal(f.store.get('SELECT count(*) AS n FROM messages').n, 0); assert.equal( f.store.get( "SELECT count(*) AS n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send'", ).n, 0, ); }); test('cross-role sends still need a matching resolved decision and consume it once', (t) => { const f = fixture(t, true), unrelated = f.raise(); assert.throws(() => f.send(undefined), /decision-required/); assert.throws(() => f.send(unrelated.id), /decision-mismatch/); const d = f.raise(f.pm, 'message.send', 'human'); assert.throws(() => f.send(d.id), /decision-not-approved/); f.call(f.agent('cto'), 'decision.resolve', { id: d.id, choice: 'yes' }); f.send(d.id); assert.throws(() => f.send(d.id), /decision-consumed/); const e = f.store.get( "SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='message.send' AND json_extract(body,'$.operation') IS NOT 'decision.raise'", ); assert.equal(JSON.parse(e.body).decision, d.id); assert.throws( () => f.b.authorize(f.pm, 'message.send', { decision: d.id, target: 'human' }), /decision-consumed/, ); });