183 lines
10 KiB
JavaScript
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');
|
|
});
|