// Row 149 (#1643, REQ-ROLE-5, REQ-DEC-5): the shipped merge-gate and security-reviewer roles on the // broker. merge-gate raises the merge itself in the technical domain, the technical arbiter resolves // it, and only merge-gate's own instance and thread can use the approval. security-reviewer answers // a review with no message.send grant: the verdict is its own path to the role that asked. 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 { Store } from '../src/store.mjs'; import { Broker } from '../src/broker.mjs'; import { busOrganization } from '../src/organization-context.mjs'; import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs'; import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs'; const TASK = 'vikunja:3/7'; const A = 'a'.repeat(40); const options = [ { key: 'yes', text: 'Land it' }, { key: 'no', text: 'Hold it' }, ]; // businessDoc's arbiters are pm (delivery) and cto (technical); the two new instances are added by // definition name, with no credentials or tracker bot. function setup(t, mutate = () => {}) { const root = mkdtempSync(join(tmpdir(), 'bus-workflow-roles-')); const doc = businessDoc(root, 'demo'); doc.roles['merge-gate'] = { definition: 'merge-gate' }; doc.roles['security-reviewer'] = { definition: 'security-reviewer' }; const business = validateBusinessDocument(doc, join(root, 'businesses', 'demo.json'), { rolesDir: REPO_ROLES }); const resolved = {}; for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business, instance }); const config = busOrganization(business, resolved); mutate(config); const store = new Store(root); const b = new Broker({ store, businesses: { demo: config } }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const caps = {}; for (const role of ['merge-gate', 'security-reviewer', 'coder', 'cto', 'pm']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); b.request(caps[role], { verb: 'role.claim', args: {} }); } const call = (role, verb, args = {}) => b.request(caps[role], { verb, args }); const raise = (role, action, extra = {}) => call(role, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'yes', blocking: false, ...extra }); return { b, store, caps, call, raise }; } test("merge-gate's technical raise is cross-role, goes to the cto, and only merge-gate can use the approval", (t) => { const { b, store, caps, call, raise } = setup(t); const d = raise('merge-gate', 'git.merge.protected', { domain: 'technical', target: TASK }); assert.equal(d.class, 'cross-role'); assert.equal(d.route_to, 'cto'); assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-not-approved/); call('cto', 'decision.resolve', { id: d.id, choice: 'yes' }); const close = store.get("SELECT from_role, to_role FROM messages WHERE class='DECISION' AND decision=?", d.id); assert.deepEqual({ ...close }, { from_role: 'cto', to_role: 'merge-gate' }); // The cto resolved it but didn't raise it, so the approval isn't the cto's to use. assert.throws(() => b.authorize(caps.cto, 'git.merge.protected', { decision: d.id, target: TASK }), /decision-mismatch/); assert.equal(b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }).class, 'cross-role'); assert.throws(() => b.authorize(caps['merge-gate'], 'git.merge.protected', { decision: d.id, target: TASK }), /decision-consumed/); }); test("merge-gate has no message.send, and without a domain its merge raise still goes to the technical arbiter", (t) => { const { call, raise } = setup(t); assert.throws(() => call('merge-gate', 'message.send', { to: 'cto', body: 'landed' }), /decision-required/); assert.equal(raise('merge-gate', 'git.merge.protected', { target: TASK }).route_to, 'cto'); // Every other action is gated for merge-gate, so an uncited raise goes to the human. const push = raise('merge-gate', 'git.push.working', { domain: 'technical', target: TASK }); assert.deepEqual([push.class, push.route_to], ['gated', 'human']); }); test("the cto must be able to message merge-gate, or the technical raise is refused (row 133)", (t) => { const { raise, store } = setup(t, (config) => { const lists = config.definitions['merge-gate'].messaging; config.definitions['merge-gate'].messaging = { ...lists, receiveFrom: lists.receiveFrom.filter((r) => r !== 'cto') }; }); assert.throws(() => raise('merge-gate', 'git.merge.protected', { domain: 'technical', target: TASK }), /decision-route-refused/); assert.equal(store.get('SELECT count(*) n FROM decisions').n, 0); }); test('security-reviewer answers a review with its verdict alone; it holds no message.send', (t) => { const { b, store, caps, call } = setup(t); b.checkPush(caps.coder, TASK); b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-3-7', commit: A, previous: null }); const r = call('coder', 'review.request', { task_ref: TASK, commit: A, to: 'security-reviewer' }); assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', r.message).to_role, 'security-reviewer'); assert.throws(() => call('security-reviewer', 'message.send', { to: 'coder', body: 'looks fine' }), /decision-required/); const v = call('security-reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'No security findings.' }); const m = store.get('SELECT from_role, to_role, class FROM messages WHERE id=?', v.message); assert.deepEqual({ ...m }, { from_role: 'security-reviewer', to_role: 'coder', class: 'REVIEW-RESULT' }); // It can't push or merge on its own. assert.throws(() => b.checkPush(caps['security-reviewer'], TASK), /decision-required/); assert.throws(() => b.authorize(caps['security-reviewer'], 'git.merge.protected', { target: TASK }), /decision-required/); });