Files
stack/packages/bus/tests/workflow-roles.test.mjs

100 lines
6.1 KiB
JavaScript

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