#threadFor puts a message to a threaded instance in the thread found up its in_reply_to chain. It checked the thread's instance but not its owner, so a tester reply to a message in a human's thread landed in the human's thread. A new #testerOwns check refuses thread-not-owned when the sender is the tester and the thread's owner isn't. It runs on both the thread branch and the row 143 spec-notice branch. A role's reply into a tester's thread is unchanged, since that's how the answer comes back. The bus README says so in its row 177 paragraph. tester-threads.test.mjs adds four T9 tests: a tester reply into the human's thread refuses and stores nothing, one into its own thread lands, the chief's reply into a tester thread lands, and a tester reply to a spec notice refuses while the chief's relay still lands. messaging.test.mjs:203 used to send as the tester into a human thread. It now sends from a tester-owned thread. Filbert approved round 1 in #1680 comment 29388. Their notes N1 (thread-instance-mismatch is checked first, as before) and N2 (BUILD.md missed project-runtime.test.mjs:13 as a text reader of broker.mjs, now corrected) need no change.
318 lines
15 KiB
JavaScript
318 lines
15 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
// Row 120 (#1614, REQ-ASSIST-2): the broker limits who a role sends to and hears from.
|
|
// The assistant's lists are read from the shipped roles/assistant.json, so the test pins the file
|
|
// the stack runs, not a copy of it.
|
|
const shipped = (name) =>
|
|
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
const assistantLists = shipped('assistant').messaging;
|
|
|
|
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
// Instance names are the business's; the lists name definitions.
|
|
const roles = {
|
|
chief: { definition: 'chief-of-staff', ...within },
|
|
assistant: { definition: 'assistant', ...within },
|
|
pm: { definition: 'pm', ...within },
|
|
coder: {
|
|
definition: 'coder',
|
|
authority: { withinRole: ['message.send', 'git.push.working', 'review.request'], crossRole: [] },
|
|
},
|
|
scribe: { definition: 'scribe', ...within },
|
|
warden: { definition: 'warden', ...within },
|
|
reviewer: {
|
|
definition: 'reviewer',
|
|
authority: { withinRole: ['message.send', 'review.verdict'], crossRole: [] },
|
|
},
|
|
};
|
|
const definitions = {
|
|
'chief-of-staff': {},
|
|
assistant: { messaging: assistantLists },
|
|
pm: {},
|
|
coder: {},
|
|
reviewer: {},
|
|
// Only a send limit: tells a dropped sender-side check apart from the target-side check.
|
|
scribe: { messaging: { sendTo: ['pm'], receiveFrom: ['human', 'pm', 'coder', 'warden', 'chief-of-staff'] } },
|
|
// Only a receive limit: the same for the target side.
|
|
warden: { messaging: { sendTo: ['human', 'pm', 'coder', 'scribe'], receiveFrom: ['pm'] } },
|
|
};
|
|
const demo = (extra = {}) => ({
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'pm', delivery: 'chief' },
|
|
roles,
|
|
definitions,
|
|
projects: { stack: { root: '/nonexistent' } },
|
|
vars: { 'bus.tester': true },
|
|
...extra,
|
|
},
|
|
});
|
|
|
|
function setup(t, businesses = demo()) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const b = new Broker({ store, businesses });
|
|
const caps = {};
|
|
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
// Row 121 (#1615): the assistant runs for one thread, and the user writes to it with thread.send.
|
|
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
|
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
|
for (const role of Object.keys(roles)) {
|
|
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
|
b.request(caps[role], { verb: 'role.claim' });
|
|
}
|
|
caps.tester = b.bindTester({ business: 'demo' });
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const send = (from, to, extra = {}) =>
|
|
from === 'human' && to === 'assistant'
|
|
? call(from, 'thread.send', { thread, body: 'hello', ...extra })
|
|
: call(from, 'message.send', { to, body: from === 'tester' ? '[mos test] hello' : 'hello', ...extra });
|
|
const count = (sql) => store.get(sql).n;
|
|
const refusals = () =>
|
|
store
|
|
.all("SELECT actor_role, body FROM events WHERE kind='action.refused' ORDER BY seq")
|
|
.map((e) => [e.actor_role, JSON.parse(e.body).code]);
|
|
return { b, store, caps, call, send, count, refusals, thread };
|
|
}
|
|
|
|
const RECIPIENT = 'message-recipient-refused';
|
|
const SENDER = 'message-sender-refused';
|
|
|
|
test('the assistant sends to the user and to the chief of staff only', (t) => {
|
|
const { send, store } = setup(t);
|
|
assert.deepEqual(assistantLists, { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['human', 'chief-of-staff'] });
|
|
for (const to of ['human', 'chief']) {
|
|
const sent = send('assistant', to);
|
|
const row = store.get('SELECT from_role, to_role FROM messages WHERE id=?', sent.id);
|
|
assert.deepEqual({ ...row }, { from_role: 'assistant', to_role: to });
|
|
}
|
|
});
|
|
|
|
test('an assistant send to any other role, or to the tester, is refused and logged', (t) => {
|
|
const { send, store, count, refusals } = setup(t);
|
|
const asked = send('tester', 'pm', { class: 'REQUEST' });
|
|
const messages = count('SELECT count(*) n FROM messages');
|
|
const allowed = count("SELECT count(*) n FROM events WHERE kind='action.allowed'");
|
|
const others = ['pm', 'coder', 'scribe', 'warden'];
|
|
for (const to of others) assert.throws(() => send('assistant', to), { code: RECIPIENT }, to);
|
|
// A reply to a real tester message is the only way past the tester gate; the list still refuses it.
|
|
assert.throws(() => send('assistant', 'tester', { in_reply_to: asked.id }), { code: RECIPIENT }, 'tester');
|
|
assert.deepEqual(
|
|
refusals(),
|
|
[...others, 'tester'].map(() => ['assistant', RECIPIENT]),
|
|
);
|
|
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
|
|
assert.equal(
|
|
count("SELECT count(*) n FROM events WHERE kind='action.allowed'"),
|
|
allowed,
|
|
'a refused send consumes no authority',
|
|
);
|
|
assert.equal(store.get("SELECT count(*) n FROM messages WHERE from_role='assistant'").n, 0);
|
|
});
|
|
|
|
test('only the user and the chief of staff may send to the assistant; the tester is refused by default', (t) => {
|
|
const { send, store, count, refusals } = setup(t);
|
|
// The chief writes to the assistant in reply to the assistant's own message (row 121).
|
|
const opened = send('assistant', 'chief');
|
|
for (const from of ['human', 'chief']) {
|
|
const sent = send(from, 'assistant', from === 'chief' ? { in_reply_to: opened.id } : {});
|
|
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', sent.id).to_role, 'assistant');
|
|
}
|
|
const messages = count('SELECT count(*) n FROM messages');
|
|
// Senders without a list of their own, so only the assistant's receive list can refuse them.
|
|
const others = ['pm', 'coder', 'tester'];
|
|
for (const from of others) assert.throws(() => send(from, 'assistant'), { code: SENDER }, from);
|
|
assert.deepEqual(refusals(), others.map((from) => [from, SENDER]));
|
|
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
|
|
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='assistant' AND from_role='tester'"), 0);
|
|
});
|
|
|
|
// Row 121 round 2 (#1615, Dewey's F1 in 28591): thread.send is the user's only way to the
|
|
// assistant, so it runs the same check as message.send.
|
|
test('REQ-ASSIST-2 REQ-THREAD-1 thread.send to an instance that does not hear from the user is refused and stores nothing', (t) => {
|
|
const businesses = demo({
|
|
definitions: { ...definitions, assistant: { messaging: { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['chief-of-staff'] } } },
|
|
});
|
|
const { send, count, refusals } = setup(t, businesses);
|
|
const messages = count('SELECT count(*) n FROM messages');
|
|
assert.throws(() => send('human', 'assistant'), { code: SENDER });
|
|
assert.deepEqual(refusals(), [[null, SENDER]]);
|
|
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no message stored');
|
|
assert.equal(count('SELECT count(*) n FROM message_threads'), 0, 'nothing filed in the thread');
|
|
assert.equal(count("SELECT count(*) n FROM events WHERE kind='human.input'"), 0, 'no instruction event');
|
|
});
|
|
|
|
test('a sender-side list alone refuses a send to a role that has no list', (t) => {
|
|
const { send, count, refusals } = setup(t);
|
|
send('scribe', 'pm');
|
|
assert.throws(() => send('scribe', 'coder'), { code: RECIPIENT });
|
|
assert.throws(() => send('scribe', 'human'), { code: RECIPIENT });
|
|
assert.deepEqual(refusals(), [['scribe', RECIPIENT], ['scribe', RECIPIENT]]);
|
|
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role='scribe'"), 1);
|
|
});
|
|
|
|
test('a target-side list alone refuses a send from a role that has no list', (t) => {
|
|
const { send, count, refusals } = setup(t);
|
|
send('pm', 'warden');
|
|
for (const from of ['coder', 'chief', 'human', 'tester'])
|
|
assert.throws(() => send(from, 'warden'), { code: SENDER }, from);
|
|
assert.deepEqual(refusals(), ['coder', 'chief', 'human', 'tester'].map((from) => [from === 'human' ? null : from, SENDER]));
|
|
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role='warden'"), 1);
|
|
});
|
|
|
|
test('the sender list is checked before the target list', (t) => {
|
|
const { send, refusals } = setup(t);
|
|
// The scribe may not send to the warden, and the warden may not hear from the scribe: one code.
|
|
assert.throws(() => send('scribe', 'warden'), { code: RECIPIENT });
|
|
assert.deepEqual(refusals(), [['scribe', RECIPIENT]]);
|
|
});
|
|
|
|
test('roles without the field behave exactly as before', (t) => {
|
|
const { send, call, count, refusals } = setup(t);
|
|
for (const [from, to] of [
|
|
['pm', 'coder'],
|
|
['coder', 'pm'],
|
|
['chief', 'pm'],
|
|
['pm', 'chief'],
|
|
['pm', 'human'],
|
|
['human', 'coder'],
|
|
['tester', 'pm'],
|
|
['tester', 'coder'],
|
|
])
|
|
send(from, to);
|
|
assert.deepEqual(refusals(), []);
|
|
assert.equal(count('SELECT count(*) n FROM messages'), 8);
|
|
// A role still answers a tester only in reply, whatever its lists.
|
|
assert.throws(() => send('pm', 'tester'), { code: 'tester-reply-required' });
|
|
const asked = send('tester', 'coder', { class: 'REQUEST' });
|
|
send('coder', 'tester', { in_reply_to: asked.id });
|
|
assert.equal(call('pm', 'message.receive').length > 0, true);
|
|
});
|
|
|
|
test('a business with no role definitions at all is unlimited', (t) => {
|
|
const businesses = demo();
|
|
delete businesses.demo.definitions;
|
|
const { b, caps, call, send, refusals } = setup(t, businesses);
|
|
// A send to the assistant still answers a message in its thread (row 121). A tester message
|
|
// reaches only a thread the tester owns (row 182), so the tester answers in a thread of its own.
|
|
const opened = send('assistant', 'pm');
|
|
send('pm', 'assistant', { in_reply_to: opened.id });
|
|
const project = call('tester', 'project.create', { name: 'test work' }).id;
|
|
const thread = call('tester', 'thread.create', { project, name: 'probe' }).id;
|
|
caps.probe = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'probe-run', harness: 'pi', thread });
|
|
call('probe', 'role.claim');
|
|
send('tester', 'assistant', { in_reply_to: call('probe', 'message.send', { to: 'pm', body: 'hello' }).id });
|
|
assert.deepEqual(refusals(), []);
|
|
});
|
|
|
|
test('a malformed messaging field refuses at boot', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-messaging-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const boot = (messaging) => () =>
|
|
new Broker({ store, businesses: demo({ definitions: { ...definitions, pm: { messaging } } }) });
|
|
const ok = { sendTo: [], receiveFrom: ['human'] };
|
|
boot(ok)();
|
|
for (const bad of [
|
|
'human',
|
|
[],
|
|
{ sendTo: [] },
|
|
{ sendTo: 'human', receiveFrom: [] },
|
|
{ sendTo: ['tester'], receiveFrom: [] },
|
|
{ sendTo: [], receiveFrom: ['tester'] },
|
|
{ sendTo: [''], receiveFrom: [] },
|
|
{ sendTo: [7], receiveFrom: [] },
|
|
])
|
|
assert.throws(boot(bad), { code: 'invalid-messaging' }, JSON.stringify(bad));
|
|
});
|
|
|
|
// Round 1 F1: review.request and review.verdict store a message too, so the same lists bind them.
|
|
// REQ-ASSIST-2 has no approval exception, so an approved decision doesn't open a path.
|
|
const TASK = 'vikunja:1/2';
|
|
const COMMIT = 'a'.repeat(40);
|
|
const options = [
|
|
{ key: 'yes', text: 'Allow' },
|
|
{ key: 'no', text: 'Decline' },
|
|
];
|
|
const pushed = (b, caps) => {
|
|
b.checkPush(caps.coder, TASK);
|
|
b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit: COMMIT, previous: null });
|
|
};
|
|
// The assistant holds no review.request, so it raises a decision for it, and the human approves.
|
|
const approved = (call) => {
|
|
const d = call('assistant', 'decision.raise', {
|
|
action: 'review.request',
|
|
target: TASK,
|
|
question: 'review?',
|
|
options,
|
|
recommendation: 'yes',
|
|
blocking: false,
|
|
});
|
|
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
|
return d.id;
|
|
};
|
|
|
|
test("the assistant's review.request to a reviewer is refused and logged, even with an approved decision", (t) => {
|
|
const { b, caps, call, count, refusals } = setup(t);
|
|
pushed(b, caps);
|
|
const decision = approved(call);
|
|
const messages = count('SELECT count(*) n FROM messages');
|
|
const used = "SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.request'";
|
|
const before = count(used);
|
|
const ask = () => call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
|
|
// The refusal leaves the decision unconsumed, so a retry meets the same list, not decision-consumed.
|
|
assert.throws(ask, { code: RECIPIENT });
|
|
assert.throws(ask, { code: RECIPIENT });
|
|
assert.deepEqual(refusals(), [['assistant', RECIPIENT], ['assistant', RECIPIENT]]);
|
|
assert.equal(count('SELECT count(*) n FROM messages'), messages, 'no REVIEW-REQUEST stored');
|
|
assert.equal(count("SELECT count(*) n FROM events WHERE kind='review.requested'"), 0);
|
|
assert.equal(count(used), before, 'a refused request consumes no authority');
|
|
});
|
|
|
|
test('a review.verdict to the assistant from a role outside its receiveFrom is refused and logged', (t) => {
|
|
// A request from before the lists existed: a first broker without the assistant's lists lets it through.
|
|
const first = setup(t, demo({ definitions: { ...definitions, assistant: {} } }));
|
|
pushed(first.b, first.caps);
|
|
const decision = approved(first.call);
|
|
const asked = first.call('assistant', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer', decision });
|
|
// A second broker on the same store enforces the shipped lists.
|
|
first.call('reviewer', 'role.release');
|
|
const b2 = new Broker({ store: first.store, businesses: demo() });
|
|
const cap = b2.bindLaunch({ business: 'demo', role: 'reviewer', run: 'reviewer-run-2', harness: 'pi' });
|
|
b2.request(cap, { verb: 'role.claim' });
|
|
const args = { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'looks fine' };
|
|
assert.throws(() => b2.request(cap, { verb: 'review.verdict', args }), { code: SENDER });
|
|
assert.deepEqual(first.refusals(), [['reviewer', SENDER]]);
|
|
assert.equal(first.count("SELECT count(*) n FROM messages WHERE class='REVIEW-RESULT'"), 0, 'no REVIEW-RESULT stored');
|
|
assert.equal(first.count("SELECT count(*) n FROM events WHERE kind='review.verdict'"), 0);
|
|
assert.equal(
|
|
first.count("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='review.verdict'"),
|
|
0,
|
|
'a refused verdict consumes no authority',
|
|
);
|
|
});
|
|
|
|
test('review requests and verdicts between roles without the field are unchanged', (t) => {
|
|
const { b, caps, call, store, refusals } = setup(t);
|
|
pushed(b, caps);
|
|
const asked = call('coder', 'review.request', { task_ref: TASK, commit: COMMIT, to: 'reviewer' });
|
|
const verdict = call('reviewer', 'review.verdict', { task_ref: TASK, request: asked.request, verdict: 'approve', body: 'ok' });
|
|
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', asked.message).to_role, 'reviewer');
|
|
assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', verdict.message).to_role, 'coder');
|
|
assert.deepEqual(refusals(), []);
|
|
});
|