Files
stack/packages/bus/tests/viewer.test.mjs

183 lines
10 KiB
JavaScript

// Row 275 (193b1b, #1780, row 193 design 4.3, 8 and 9): the viewer level on the bus. A viewer grant
// binds and reaches VIEWER_VERBS only; thread.create, thread.open and thread.send need a collaborator
// grant on the project and instance. Board reads refuse every user, collaborator or viewer, and the
// user-side governance pin names each item of the 5.7 user sentence. The fixture is user-principal's.
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, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS,
} from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-viewer-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant()] } };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => access.doc,
});
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const user = (name) => (caps[name] = b.bindUser({ business: 'demo', user: name }));
const launch = (name, thread) => {
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
return call(name, 'role.claim');
};
return { b, store, access, caps, call, code, user, launch };
}
// Two projects. Alice, a collaborator on p1, made thread A there and sent one message; then her grant
// is lowered to viewer (the downgrade path, design 4.3). Bob is a viewer on p1 from the start.
function viewers(t) {
const ctx = setup(t);
const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id;
const p2 = ctx.call('human', 'project.create', { name: 'private' }).id;
ctx.access.doc.grants = [grant({ projects: [p1] })];
ctx.user('alice');
const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = ctx.call('alice', 'thread.send', { thread: A, body: 'hello' }).id;
ctx.access.doc.grants = [
grant({ projects: [p1], level: 'viewer' }),
grant({ id: 'g-b', user: 'bob', projects: [p1], level: 'viewer' }),
];
ctx.user('bob');
return { ...ctx, p1, p2, A, sent };
}
test('275: VIEWER_VERBS is the ruled read list, inside USER_VERBS', () => {
assert.deepEqual(VIEWER_VERBS, ['project.list', 'thread.list', 'thread.messages', 'names']);
for (const v of VIEWER_VERBS) assert.ok(USER_VERBS.includes(v), v);
});
test('275: a viewer binds, and each viewer verb answers with what its grant covers', (t) => {
const { call, p1, A, sent } = viewers(t);
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
for (const who of ['alice', 'bob']) {
assert.deepEqual(call(who, 'project.list').map((p) => p.id), [p1], who);
assert.deepEqual(call(who, 'names').map((n) => n.instance), ['assistant'], who);
}
// Alice's own old thread still lists and reads; bob has none.
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent]);
assert.deepEqual(call('bob', 'thread.list'), []);
});
// Mutant 6 (row 193 design 8): a viewer allowed thread.send. This test kills it.
test('275: a viewer is refused thread.create, thread.open and thread.send access-refused, and nothing is stored', (t) => {
const { store, code, p1, A } = viewers(t);
const counts = () => ['messages', 'threads', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
const before = counts();
for (const who of ['alice', 'bob'])
assert.equal(code(who, 'thread.create', { project: p1, name: `${who} new` }), 'access-refused', `${who} create`);
// Only alice owns a thread: #thread refuses a non-owner thread-not-owned before the level check
// (duckworth-0166 B1), so open and send are asked on her own A.
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'alice open');
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still here?' }), 'access-refused', 'alice send');
assert.deepEqual(counts(), before);
});
test('275: every verb off the user list refuses a viewer access-refused before its own checks', (t) => {
const { b, caps } = viewers(t);
const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others)
assert.throws(() => b.request(caps.bob, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
});
test('275: mixed grants, collaborator on one project and viewer on another, take the level per project', (t) => {
const { call, code, access, user } = setup(t);
const pa = call('human', 'project.create', { name: 'a' }).id;
const pb = call('human', 'project.create', { name: 'b' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb] })];
user('alice');
const B = call('alice', 'thread.create', { project: pb, name: 'b work' }).id;
const inB = call('alice', 'thread.send', { thread: B, body: 'in b, before' }).id;
access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb], level: 'viewer' })];
const A = call('alice', 'thread.create', { project: pa, name: 'a work' }).id;
assert.ok(A, 'create in the collaborator project works');
assert.equal(code('alice', 'thread.create', { project: pb, name: 'b more' }), 'access-refused', 'create in the viewer project');
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [pa, pb]);
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [B, A]);
assert.ok(call('alice', 'thread.send', { thread: A, body: 'in a' }).id);
assert.equal(code('alice', 'thread.send', { thread: B, body: 'in b' }), 'access-refused');
assert.equal(code('alice', 'thread.open', { thread: B }), 'access-refused');
assert.deepEqual(call('alice', 'thread.messages', { thread: B }).map((m) => m.id), [inB]);
});
// Mutant 10 (row 193 design 8): a user allowed `trail`. This test kills it; R3's exact USER_VERBS
// pin in user-principal.test.mjs fails too. Carol is a collaborator, so the refusal isn't the level's.
test('275: board reads refuse users and viewers access-refused', (t) => {
const { code, access, user, p1, A } = viewers(t);
assert.deepEqual(BOARD_VERBS, ['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
for (const who of ['alice', 'bob', 'carol']) {
for (const verb of [...BOARD_VERBS, ...PROJECT_FILE_VERBS, 'limits', 'plugins', 'instances', 'routes'])
assert.equal(code(who, verb, verb === 'trail' ? { subject: A } : {}), 'access-refused', `${who} ${verb}`);
}
});
// Row 193's user sentence, from the user's side (mos-1023 item 2, design 8): "The business owner sees
// your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your
// messages." The owner side is message-filter.test.mjs's pin. Here each item the owner sees is a
// board read the user is refused, at either level, while the user still reads its own thread.
test('governance pin, user side: decisions, inbox entries, claims, tasks and project files are the owner\'s, the user reads its messages', (t) => {
const { call, code, access, user, launch, p1 } = viewers(t);
access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })];
user('carol');
const C = call('carol', 'thread.create', { project: p1, name: 'carol work' }).id;
const sent = call('carol', 'thread.send', { thread: C, body: 'please plan it' }).id;
launch('a', C);
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'the owner sees the raise');
const items = [
['decisions', 'decisions', { list: 'recent' }],
['inbox entries', 'inbox', {}],
['claims', 'agents', {}],
['claims', 'thread.claims', {}],
['tasks', 'tasks', {}],
['tasks', 'state', {}],
['project files', 'project.files', { project: 'stack' }],
['project files', 'project.file', { project: 'stack', path: 'docs/plan.md' }],
];
for (const who of ['carol', 'bob'])
for (const [item, verb, args] of items) assert.equal(code(who, verb, args), 'access-refused', `${item}: ${who} ${verb}`);
assert.deepEqual(call('carol', 'thread.messages', { thread: C }).map((m) => m.id), [sent], 'messages: the user reads its own');
});