433 lines
25 KiB
JavaScript
433 lines
25 KiB
JavaScript
// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal.
|
|
// A user binds only with a live grant (row 275: a viewer grant binds too; viewer.test.mjs has the
|
|
// viewer cases), reaches the exact collaborator verb list, sees only what its grants cover, and
|
|
// talks to the granted instance in its own threads. The access
|
|
// document comes in as a function, as the runtime passes R1's loadAccess; each test changes it.
|
|
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, TESTER_VERBS, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, CREDENTIAL_VERBS,
|
|
PROJECT_FILE_VERBS, LIBRARY_VERBS, TESTER_ORGANIZATION_VERBS, GRANTS_VERBS, LIFECYCLE_HUMAN_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,
|
|
});
|
|
|
|
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
|
function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
// The document a test edits between requests; `reads` counts the loader calls.
|
|
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants }, reads: 0, fail: false };
|
|
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: [] } },
|
|
...threadRoles,
|
|
},
|
|
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
|
},
|
|
},
|
|
...(processing ? { processing } : {}),
|
|
access: () => {
|
|
access.reads++;
|
|
if (access.fail) throw new Error('access file refused');
|
|
return access.doc;
|
|
},
|
|
});
|
|
const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
};
|
|
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, as = name) => (caps[as] = 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');
|
|
};
|
|
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
|
|
return { b, store, access, caps, call, code, user, launch, inputs };
|
|
}
|
|
|
|
// Two projects, alice granted only `p1`, and alice's thread A in it.
|
|
function granted(t, options) {
|
|
const ctx = setup(t, options);
|
|
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;
|
|
return { ...ctx, p1, p2, A };
|
|
}
|
|
|
|
test('R3: bindUser binds a granted user and refuses one with no live grant', (t) => {
|
|
const { b, access, user } = setup(t);
|
|
assert.ok(user('alice'));
|
|
// Row 275 (193b1b): a viewer grant binds; it used to refuse here as the case 'viewer level'.
|
|
access.doc.grants = [grant({ level: 'viewer' })];
|
|
assert.ok(user('alice'), 'viewer level');
|
|
for (const [what, grants] of [
|
|
['no grant', []],
|
|
['another user', [grant({ user: 'bob' })]],
|
|
['another business', [grant({ business: 'other' })]],
|
|
['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]],
|
|
]) {
|
|
access.doc.grants = grants;
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what);
|
|
}
|
|
access.doc.grants = [grant()];
|
|
access.fail = true;
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'unreadable file');
|
|
access.fail = false;
|
|
access.doc = null;
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'no file');
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice', extra: 1 }), { code: 'invalid-request' });
|
|
});
|
|
|
|
test('R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say', (t) => {
|
|
const { b, access } = setup(t);
|
|
for (const name of ['jason', 'human', 'tester', 'user']) {
|
|
access.doc.grants = [grant({ user: name })];
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: name }), { code: 'access-refused' }, name);
|
|
}
|
|
access.doc.grants = [grant({ user: 'Alice!' })];
|
|
assert.throws(() => b.bindUser({ business: 'demo', user: 'Alice!' }), { code: 'access-refused' });
|
|
});
|
|
|
|
test('R3: each collaborator verb is allowed to a granted user', (t) => {
|
|
const { call, store, p1, A } = granted(t);
|
|
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]);
|
|
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
|
|
assert.deepEqual(call('alice', 'thread.open', { thread: A }), { opened: A });
|
|
const sent = call('alice', 'thread.send', { thread: A, body: 'hello' });
|
|
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]);
|
|
assert.ok(Array.isArray(call('alice', 'names')));
|
|
assert.equal(store.get('SELECT owner FROM threads WHERE id=?', A).owner, 'user:alice');
|
|
assert.equal(store.get('SELECT by FROM thread_opens WHERE thread=?', A).by, 'user:alice');
|
|
});
|
|
|
|
test('R3: every verb off the collaborator list refuses a user access-refused before its own checks', (t) => {
|
|
const { b, caps } = granted(t);
|
|
const others = new Set([
|
|
...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...LIBRARY_VERBS, ...CREDENTIAL_VERBS, ...HOSTS_VERBS,
|
|
// R3 r6 (sage-2576): the grants, lifecycle, web and host lists, each refused at the user gate too.
|
|
...GRANTS_VERBS, ...LIFECYCLE_HUMAN_VERBS, ...WEB_VERBS, ...HOST_VERBS,
|
|
'decision.raise', 'decision.resolve', 'decision.seen', 'role.claim', 'role.release', 'role.end', 'record.write', 'spec.read',
|
|
'usage.begin', 'usage.end', 'limits.set', 'organization.set', 'organization.revision', 'review.request', 'review.verdict',
|
|
'staff.request', 'state', 'input', 'launch', 'no.such.verb',
|
|
]);
|
|
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
|
|
// rather than leaving the set below with the verb it added (M3x, M6x).
|
|
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
|
|
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
|
|
'thread.share', 'thread.unshare', 'thread.shares']);
|
|
for (const v of USER_VERBS) others.delete(v);
|
|
assert.ok(others.size > 40);
|
|
for (const verb of others) {
|
|
// Bad arguments on purpose: the refusal comes before any argument check.
|
|
assert.throws(() => b.request(caps.alice, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
|
|
}
|
|
});
|
|
|
|
test('R3: task, web and host verbs refuse a user access-refused', async (t) => {
|
|
const { b, caps } = granted(t);
|
|
for (const verb of TASK_VERBS)
|
|
await assert.rejects(b.requestTask(caps.alice, { verb, args: {} }, async () => ({})), { code: 'access-refused' }, verb);
|
|
for (const verb of WEB_VERBS)
|
|
await assert.rejects(b.requestWeb(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb);
|
|
for (const verb of HOST_VERBS)
|
|
await assert.rejects(b.requestHost(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb);
|
|
});
|
|
|
|
test('R3 leak: project.list shows granted projects only', (t) => {
|
|
const { call, access, p1, p2 } = granted(t);
|
|
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]);
|
|
assert.deepEqual(call('human', 'project.list').map((p) => p.id), [p1, p2]);
|
|
access.doc.grants = [grant()];
|
|
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1, p2]);
|
|
});
|
|
|
|
test('R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist', (t) => {
|
|
const { call, code, access, p1, p2 } = granted(t);
|
|
assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused');
|
|
assert.equal(code('alice', 'thread.create', { project: '00000000-0000-4000-8000-000000000000', name: 'x' }), 'access-refused');
|
|
access.doc.grants = [grant({ projects: [p1] }), grant({ id: 'g-2', projects: [p2], instances: ['chief'] })];
|
|
assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused', 'another instance');
|
|
assert.ok(call('alice', 'thread.create', { project: p1, name: 'second' }).id);
|
|
});
|
|
|
|
test('R3 leak: thread.list shows the user its own threads that are still granted, never others', (t) => {
|
|
const { call, code, access, user, p1, p2, A } = granted(t);
|
|
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
|
const T = call('tester', 'thread.create', { project: p1, name: 'probe' }).id;
|
|
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
|
user('bob');
|
|
const B = call('bob', 'thread.create', { project: p2, name: 'bob work' }).id;
|
|
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
|
|
assert.deepEqual(call('bob', 'thread.list').map((x) => x.id), [B]);
|
|
// Neither the owner's nor the tester's lists carry a user's thread.
|
|
assert.deepEqual(call('human', 'thread.list').map((x) => x.id), [H]);
|
|
assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T]);
|
|
for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]]) {
|
|
for (const other of [B, H, T]) assert.equal(code('alice', verb, { thread: other, ...args }), 'thread-not-owned', `${verb} ${other}`);
|
|
assert.equal(code('human', verb, { thread: A, ...args }), 'thread-not-owned', `human ${verb}`);
|
|
assert.equal(code('tester', verb, { thread: A, ...args }), 'thread-not-owned', `tester ${verb}`);
|
|
}
|
|
// A thread whose project is no longer granted drops off the list and refuses the thread verbs.
|
|
access.doc.grants = [grant({ projects: [p2] })];
|
|
assert.deepEqual(call('alice', 'thread.list'), []);
|
|
for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]])
|
|
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
|
|
});
|
|
|
|
test('R3 leak: names shows granted instances only', (t) => {
|
|
const { call, access } = granted(t);
|
|
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
|
|
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
|
|
assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant']);
|
|
assert.deepEqual(call('human', 'names').map((n) => n.instance), ['assistant', 'chief']);
|
|
access.doc.grants = [grant({ instances: ['*'] })];
|
|
assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant', 'chief']);
|
|
});
|
|
|
|
test('R3: thread.send is from user:<id> and writes no human.input', (t) => {
|
|
const { store, call, inputs, A } = granted(t);
|
|
const before = inputs();
|
|
const sent = call('alice', 'thread.send', { thread: A, body: 'please draft it' });
|
|
assert.equal(sent.request, null);
|
|
assert.equal(inputs(), before);
|
|
const m = store.get('SELECT * FROM messages WHERE id=?', sent.id);
|
|
assert.equal(m.from_role, 'user');
|
|
assert.equal(m.from_run, 'user:alice');
|
|
assert.equal(m.to_role, 'assistant');
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', sent.id).thread, A);
|
|
});
|
|
|
|
test('R3: a reply-chain message.send into another user thread is refused', (t) => {
|
|
const { call, code, access, user, p1 } = granted(t);
|
|
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
|
user('bob');
|
|
const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id;
|
|
const asked = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id;
|
|
assert.equal(code('alice', 'message.send', { to: 'assistant', body: 'me too', in_reply_to: asked }), 'access-refused');
|
|
assert.equal(call('bob', 'thread.messages', { thread: B }).length, 1);
|
|
});
|
|
|
|
test('R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice', (t) => {
|
|
const { store, call, launch, A } = granted(t);
|
|
const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id;
|
|
launch('a', A);
|
|
const before = store.get('SELECT count(*) n FROM messages').n;
|
|
const reply = call('a', 'message.send', { to: 'user', body: 'answer', class: 'RESULT', in_reply_to: asked });
|
|
assert.equal('waiting' in reply, false);
|
|
assert.equal(reply.request, null);
|
|
assert.equal(store.get('SELECT count(*) n FROM messages').n, before + 1, 'no notice message');
|
|
const r = store.get('SELECT * FROM messages WHERE id=?', reply.id);
|
|
assert.equal(r.to_role, 'user');
|
|
assert.equal(r.from_run, 'a-run');
|
|
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [asked, reply.id]);
|
|
// The owner's and the tester's receive get nothing of it.
|
|
assert.deepEqual(call('human', 'message.receive'), []);
|
|
assert.deepEqual(call('tester', 'message.receive'), []);
|
|
});
|
|
|
|
test('R3: a reply to: user without a matching in_reply_to is refused user-reply-required', (t) => {
|
|
const { store, call, code, access, user, launch, p1, A } = granted(t);
|
|
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
|
user('bob');
|
|
const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id;
|
|
const fromBob = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id;
|
|
const fromAlice = call('alice', 'thread.send', { thread: A, body: 'alice asks' }).id;
|
|
const A2 = call('alice', 'thread.create', { project: p1, name: 'alice other' }).id;
|
|
const elsewhere = call('alice', 'thread.send', { thread: A2, body: 'alice asks elsewhere' }).id;
|
|
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
|
const fromOwner = call('human', 'thread.send', { thread: H, body: 'owner asks' }).id;
|
|
launch('a', A);
|
|
const send = (in_reply_to) => code('a', 'message.send', { to: 'user', body: 'x', ...(in_reply_to ? { in_reply_to } : {}) });
|
|
assert.equal(send(null), 'user-reply-required', 'no in_reply_to');
|
|
assert.equal(send(fromBob), 'user-reply-required', 'another user message in another thread');
|
|
assert.equal(send(elsewhere), 'user-reply-required', 'the same user message outside the thread');
|
|
assert.equal(send(fromOwner), 'user-reply-required', 'the owner message');
|
|
assert.equal(send('00000000-0000-4000-8000-000000000000'), 'user-reply-required', 'an unknown id');
|
|
assert.equal(send(fromAlice), null, 'its own user message in its own thread');
|
|
assert.equal(store.get("SELECT count(*) n FROM messages WHERE to_role='user'").n, 1);
|
|
});
|
|
|
|
test('R3: a non-thread run, the human and the tester cannot send to: user', (t) => {
|
|
const { b, call, code, A } = granted(t);
|
|
const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id;
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
assert.throws(() => b.request(chief, { verb: 'message.send', args: { to: 'user', body: 'x', in_reply_to: asked } }), { code: 'user-reply-required' });
|
|
assert.equal(code('human', 'message.send', { to: 'user', body: 'x' }), 'unknown-role');
|
|
assert.equal(code('tester', 'message.send', { to: 'user', body: 'x' }), 'unknown-role');
|
|
});
|
|
|
|
test('R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9', (t) => {
|
|
const { store, call, code, launch, A } = granted(t);
|
|
call('alice', 'thread.send', { thread: A, body: 'ship it' });
|
|
launch('a', A);
|
|
assert.equal(code('a', 'message.send', { to: 'human', body: 'psst' }), 'user-thread-human');
|
|
const before = store.get('SELECT count(*) n FROM messages').n;
|
|
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
|
|
assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', d).route_to, 'human');
|
|
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'in the owner inbox');
|
|
assert.equal(store.get('SELECT count(*) n FROM messages').n, before, 'no message to any role');
|
|
assert.equal(code('alice', 'inbox'), 'access-refused', 'the user has no inbox');
|
|
assert.equal(code('a', 'message.send', { to: 'human', body: 'about that raise' }), 'user-thread-human');
|
|
});
|
|
|
|
test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => {
|
|
const { call, code, access, store, A } = granted(t);
|
|
const reads = access.reads;
|
|
call('alice', 'thread.list');
|
|
call('alice', 'project.list');
|
|
assert.equal(access.reads, reads + 2);
|
|
const kept = access.doc.grants;
|
|
access.doc.grants = [];
|
|
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed');
|
|
access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' }));
|
|
assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired');
|
|
// Row 275 (193b1b, design 4.3): a grant lowered to viewer still reads the user's own threads.
|
|
access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' }));
|
|
assert.equal(code('alice', 'thread.list'), null, 'downgraded');
|
|
// mos-1311 condition 1: the downgraded viewer still can't send or open, and nothing is stored.
|
|
const stored = () => ['messages', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
|
|
const before = stored();
|
|
assert.equal(code('alice', 'thread.send', { thread: A, body: 'still mine?' }), 'access-refused', 'downgraded send');
|
|
assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'downgraded open');
|
|
assert.deepEqual(stored(), before, 'downgraded: nothing stored');
|
|
access.fail = true;
|
|
access.doc.grants = kept;
|
|
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused');
|
|
access.fail = false;
|
|
assert.equal(code('alice', 'thread.list'), null, 'restored');
|
|
});
|
|
|
|
test('R3: a user refusal is recorded as action.refused by user:<id>', (t) => {
|
|
const { store, code, access, A } = granted(t);
|
|
assert.equal(code('alice', 'inbox'), 'access-refused');
|
|
access.doc.grants = [];
|
|
assert.equal(code('alice', 'thread.messages', { thread: A }), 'access-revoked');
|
|
const rows = store.all("SELECT actor_role, actor_run, body FROM events WHERE kind='action.refused' AND actor_run='user:alice' ORDER BY seq");
|
|
assert.deepEqual(rows.map((r) => [r.actor_role, JSON.parse(r.body).code]), [['user', 'access-refused'], ['user', 'access-revoked']]);
|
|
});
|
|
|
|
test('R3: a business with a role keyed user refuses invalid-business', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-boot-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const businesses = {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
user: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
},
|
|
};
|
|
assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' });
|
|
delete businesses.demo.roles.user;
|
|
assert.ok(new Broker({ store, businesses }), 'the same business without it loads');
|
|
});
|
|
|
|
test('R3: name.set refuses the name user', (t) => {
|
|
const { code } = setup(t);
|
|
assert.equal(code('human', 'name.set', { instance: 'assistant', name: 'user' }), 'name-is-instance');
|
|
});
|
|
|
|
// FED R3 r4 (sage-2486): halt is the kill switch, so it refuses a granted user's write as it refuses
|
|
// the human's. The read still answers, and nothing is stored.
|
|
test('R3 r4: while halted, a granted user\'s thread.send refuses processing-halted and thread.messages still answers', (t) => {
|
|
const generation = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
|
|
const sw = { state: { version: 1, halted: false, generation } };
|
|
const { call, code, store, A } = granted(t, { processing: { read: () => sw.state } });
|
|
const sent = call('alice', 'thread.send', { thread: A, body: 'before' });
|
|
sw.state = { version: 1, halted: true, generation };
|
|
const stored = store.get('SELECT count(*) n FROM messages').n;
|
|
assert.equal(code('alice', 'thread.send', { thread: A, body: 'during' }), 'processing-halted');
|
|
assert.equal(store.get('SELECT count(*) n FROM messages').n, stored);
|
|
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]);
|
|
});
|
|
|
|
// FED R3 r4 (sage-2557): row 194's hire takes an instance name, and `user` is refused as human and
|
|
// tester are, so a hire can't write a role the load check refuses.
|
|
test('R3 r4: the chief\'s hire refuses an instance named user with the code it gives human and tester', (t) => {
|
|
const { b, code, caps } = setup(t);
|
|
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
|
b.request(caps.chief, { verb: 'role.claim' });
|
|
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
|
|
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
|
|
// A name that isn't reserved passes the name check and refuses later, for want of role files.
|
|
assert.notEqual(hire('helper'), 'invalid-request');
|
|
});
|
|
|
|
// FED R3 r4 (sage-2557, sage-2501): `access` is checked with the other function arguments, before
|
|
// the broker touches the store, so a null store never gets read.
|
|
test('R3 r4: a non-function access throws before the store is read', () => {
|
|
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
|
});
|
|
|
|
test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
|
const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
|
const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
|
const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
|
ctx.access.doc.grants = [grant({ projects: [project] })];
|
|
ctx.user('alice');
|
|
assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
|
for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
|
assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
|
assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
|
const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
|
assert.equal(allowed.instance, 'assistant');
|
|
ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
|
const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
|
assert.equal(bean.instance, 'bean');
|
|
assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
|
});
|
|
|
|
test('264e deferred B6: a reply cannot cite the same user in a sibling session', t => {
|
|
const { b, store, caps, call, A } = granted(t);
|
|
// Session writes remain off USER_VERBS (Q7). Seed the future multi-session case directly,
|
|
// without granting a collaborator new authority just to exercise the reply fence.
|
|
const sibling = 'f0000000-0000-4000-8000-000000000001';
|
|
store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,?,instance,owner,'sibling','264e-fixture' FROM threads WHERE id=?", sibling, A);
|
|
const original = call('alice', 'thread.send', { thread: A, body: 'original asks' }).id;
|
|
const second = call('alice', 'thread.send', { thread: A, session: sibling, body: 'sibling asks' }).id;
|
|
caps.worker = b.bindLaunch({ business:'demo', role:'assistant', run:'session-worker', harness:'pi', thread:A, session:sibling });
|
|
call('worker', 'role.claim');
|
|
const before = store.get('SELECT count(*) n FROM messages').n;
|
|
assert.throws(() => call('worker', 'message.send', { to:'user', in_reply_to:original, body:'wrong context' }), { code:'user-reply-required' });
|
|
assert.equal(store.get('SELECT count(*) n FROM messages').n, before);
|
|
const reply = call('worker', 'message.send', { to:'user', in_reply_to:second, body:'right context' });
|
|
assert.equal(store.get('SELECT session FROM message_threads WHERE message=?', reply.id).session, sibling);
|
|
});
|