Files

178 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Row 81 (#1575): `replies`, the read behind the Console's web-send receipt. The messages
// answering one message, oldest first, at most REPLIES, for human and reader sessions. It
// writes no delivery or read row, so the human's next message.receive (mosaic talk) still
// gets each reply.
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, REPLIES } from '../src/broker.mjs';
import { views } from '../src/views.mjs';
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-replies-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const agent = (role, business = 'demo') => {
const cap = b.bindLaunch({ business, role, run: `${business}-${role}-run`, harness: 'pi', address: `${business}-${role}-run` });
b.request(cap, { verb: 'role.claim' });
return cap;
};
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
const coder = agent('coder');
const pm = agent('pm');
// The human's web send, as row 76's Console makes it.
const sent = call(b, human, 'message.send', { to: 'coder', body: 'Please look at row 81.' });
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq');
const count = () =>
['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
(table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
);
return { b, store, agent, human, reader, coder, pm, sent, deliveries, count };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const reply = (b, cap, to, body, extra = {}) =>
call(b, cap, 'message.send', { to: 'human', body, class: 'RESULT', in_reply_to: to, ...extra });
test('send receipts derive request provenance only from a valid reply chain', (t) => {
const { b, human, coder, pm, sent } = setup(t);
assert.ok(sent.request, 'a human send creates the original request');
const relay = call(b, coder, 'message.send', { to: 'pm', in_reply_to: sent.id, body: 'Please handle this' });
assert.equal(relay.request, sent.request);
const onward = call(b, pm, 'message.send', { to: 'human', in_reply_to: relay.id, body: 'Handled' });
assert.equal(onward.request, sent.request);
const unlinked = call(b, pm, 'message.send', { to: 'coder', body: `request ${sent.request}` });
assert.equal(unlinked.request, null, 'body text does not confer provenance');
const wrongRecipient = call(b, pm, 'message.send', { to: 'human', in_reply_to: sent.id, body: 'Not the recipient' });
assert.equal(wrongRecipient.request, null, 'a sender cannot claim another role’s reply');
assert.throws(() => call(b, coder, 'message.send', { to: 'pm', body: 'forged', request: sent.request }), { code: 'invalid-request' });
assert.ok(call(b, human, 'message.send', { to: 'pm', body: 'Separate request' }).request);
});
test('a reply is listed for human and reader sessions, and its delivery rows are unchanged', (t) => {
const { b, store, human, reader, coder, pm, sent, deliveries, count } = setup(t);
call(b, coder, 'message.receive');
const answer = reply(b, coder, sent.id, 'Done: 3 files.');
// Not replies to the send: a message to the human with no in_reply_to, and a reply to another message.
call(b, pm, 'message.send', { to: 'human', body: 'fyi' });
const other = call(b, human, 'message.send', { to: 'pm', body: 'Another question' });
reply(b, pm, other.id, 'Another answer');
const before = deliveries();
const counts = count();
for (const cap of [human, reader]) {
const rows = call(b, cap, 'replies', { id: sent.id });
assert.deepEqual(
rows.map((m) => [m.id, m.from_role, m.to_role, m.class, m.in_reply_to, m.body]),
[[answer.id, 'coder', 'human', 'RESULT', sent.id, 'Done: 3 files.']],
);
// The whole row is the messages row trail gives the same session for that id.
const { table, ...m } = call(b, cap, 'trail', { subject: answer.id }).find((r) => r.table === 'messages');
assert.equal(table, 'messages');
assert.deepEqual({ ...rows[0] }, m);
}
assert.deepEqual(deliveries(), before, 'no delivered or read row, nothing else either');
assert.deepEqual(count(), counts, 'a successful read writes nothing');
assert.deepEqual(
store.all("SELECT op FROM deliveries WHERE message=? ORDER BY seq", answer.id).map((d) => d.op),
['routed'],
);
});
test('the reply still reaches the human message.receive (mosaic talk) after the Console showed it', (t) => {
const { b, human, reader, coder, sent } = setup(t);
const answer = reply(b, coder, sent.id, 'Done.');
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
assert.deepEqual(call(b, human, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
const got = call(b, human, 'message.receive');
assert.deepEqual(got.map((m) => [m.id, m.in_reply_to, m.body]), [[answer.id, sent.id, 'Done.']]);
assert.deepEqual(call(b, human, 'message.read', { id: answer.id }), { read: true });
assert.deepEqual(call(b, human, 'message.receive'), []);
// Once received and read it is still listed: the read is history, not an inbox.
assert.deepEqual(call(b, reader, 'replies', { id: sent.id }).map((m) => m.id), [answer.id]);
});
test('a message with no reply gives an empty list', (t) => {
const { b, human, reader, sent } = setup(t);
for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'replies', { id: sent.id }), []);
});
test('replies come oldest first and stop at REPLIES', (t) => {
const { b, reader, coder, pm, sent } = setup(t);
assert.equal(REPLIES, 50);
const ids = [];
for (let i = 0; i <= REPLIES; i++) ids.push(reply(b, i % 2 ? pm : coder, sent.id, `reply ${i}`).id);
const rows = call(b, reader, 'replies', { id: sent.id });
assert.equal(rows.length, REPLIES);
assert.deepEqual(
rows.map((m) => m.id),
ids.slice(0, REPLIES),
);
assert.deepEqual(rows.slice(0, 3).map((m) => [m.from_role, m.body]), [
['coder', 'reply 0'],
['pm', 'reply 1'],
['coder', 'reply 2'],
]);
});
test('an id that names no message in the business refuses message-not-found, and the refusal is recorded', (t) => {
const { b, store, agent, human, reader, pm, sent } = setup(t);
const elsewhere = call(b, agent('coder', 'other'), 'message.send', { to: 'human', body: 'not demo' });
const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
for (const cap of [human, reader])
for (const id of ['00000000-0000-4000-8000-000000000000', elsewhere.id, 'vikunja:3/1']) {
const before = refusals();
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'message-not-found' }, id);
assert.equal(refusals(), before + 1, 'the refusal is recorded');
}
// A decision id is not a message id either.
const d = call(b, pm, 'decision.raise', {
action: 'deploy',
target: 'release-1',
question: 'Deploy?',
options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }],
recommendation: 'yes',
blocking: false,
});
assert.throws(() => call(b, reader, 'replies', { id: d.id }), { code: 'message-not-found' });
// The other business's reader finds its own message, not demo's.
const otherReader = b.bindReader({ business: 'other' });
assert.deepEqual(call(b, otherReader, 'replies', { id: elsewhere.id }), []);
assert.throws(() => call(b, otherReader, 'replies', { id: sent.id }), { code: 'message-not-found' });
});
test('strict arguments; a role session gets human-required', (t) => {
const { b, human, reader, coder, pm, sent } = setup(t);
for (const cap of [human, reader])
for (const args of [{}, { id: '' }, { id: 7 }, { id: [sent.id] }, { id: '<b>' }, { id: sent.id, limit: 5 }, { subject: sent.id }])
assert.throws(() => call(b, cap, 'replies', args), { code: 'invalid-request' }, JSON.stringify(args));
// A role can't read the human's replies, even to a message it sent itself.
const own = call(b, pm, 'message.send', { to: 'coder', body: 'hi' });
for (const [cap, id] of [[coder, sent.id], [pm, own.id]])
assert.throws(() => call(b, cap, 'replies', { id }), { code: 'human-required' });
// The reader's other limits stand.
assert.throws(() => call(b, reader, 'message.receive'), { code: 'read-only' });
});
test('views passes replies through as one call with the id', async () => {
const calls = [];
const read = views({ call: async (verb, args) => (calls.push([verb, args]), []) });
assert.deepEqual(await read.replies('m-1'), []);
assert.deepEqual(calls, [['replies', { id: 'm-1' }]]);
});