248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import * as policy from '../src/org-rights.mjs';
|
|
import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs';
|
|
|
|
// Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are
|
|
// written out: adding a real bus verb or widening a bundle must change this pin.
|
|
const RIGHTS = {
|
|
'org.view': { grantable: true },
|
|
'thread.read': { grantable: true },
|
|
'thread.write': { grantable: true },
|
|
'board.read': { grantable: true },
|
|
'task.write': { grantable: true },
|
|
decide: { grantable: true },
|
|
'org.hire': { grantable: true },
|
|
'request.write': { grantable: false },
|
|
'members.manage': { grantable: false },
|
|
'org.setup': { grantable: false },
|
|
'org.lifecycle': { grantable: false },
|
|
};
|
|
const BUNDLES = {
|
|
owner: ['board.read', 'decide', 'members.manage', 'org.hire', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
|
|
member: ['board.read', 'org.view', 'thread.read'],
|
|
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
|
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
|
};
|
|
const VERBS = {
|
|
'autonomy.get.console': null,
|
|
'autonomy.set.console': null,
|
|
'autonomy.review.console': null,
|
|
'decision.confirm.console': null,
|
|
'decision.reverse.console': null,
|
|
'project.list': 'org.view',
|
|
names: 'org.view',
|
|
'thread.list': 'thread.read',
|
|
'thread.messages': 'thread.read',
|
|
plugins: 'thread.read',
|
|
'thread.open': 'thread.write',
|
|
'thread.create': 'thread.write',
|
|
'thread.send': 'thread.write',
|
|
'thread.share': 'thread.write',
|
|
'thread.unshare': 'thread.write',
|
|
'thread.shares': 'thread.write',
|
|
inbox: 'board.read',
|
|
agents: 'board.read',
|
|
'thread.claims': 'board.read',
|
|
tasks: 'board.read',
|
|
decisions: 'board.read',
|
|
usage: 'board.read',
|
|
limits: 'board.read',
|
|
'request.get.console': 'board.read',
|
|
'request.projects.console': 'board.read',
|
|
'request.artifact.console': 'board.read',
|
|
'request.workspace.console': 'board.read',
|
|
trail: null,
|
|
credentials: null,
|
|
replies: null,
|
|
messages: null,
|
|
state: null,
|
|
'tasks.create': 'task.write',
|
|
'tasks.edit': 'task.write',
|
|
'tasks.archive': 'task.write',
|
|
'tasks.unarchive': 'task.write',
|
|
'tasks.delete': null,
|
|
'decision.preview.console': 'decide',
|
|
'decision.resolve.console': 'decide',
|
|
'hire.resolve.console': 'org.hire',
|
|
'hire.preview.console': 'org.hire',
|
|
'hire.start.console': 'org.hire',
|
|
'hire.templates.console': 'org.hire',
|
|
'definition.import.console': 'org.hire',
|
|
'definition.edit.console': 'org.hire',
|
|
'definition.invent.console': 'org.hire',
|
|
'definition.remove.console': 'org.hire',
|
|
'request.answer.console': 'request.write',
|
|
'request.attach.console': 'request.write',
|
|
'request.cancel.console': 'request.write',
|
|
'request.mode.console': 'request.write',
|
|
'request.draft.console': 'request.write',
|
|
'prd.revoke.console': 'request.write',
|
|
'plugins.set': 'org.setup',
|
|
'system-types.console': 'org.setup',
|
|
'project.remote.get.console': 'org.setup',
|
|
'project.remote.set.console': 'org.setup',
|
|
};
|
|
|
|
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
|
|
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
|
|
});
|
|
|
|
test('R1 org.hire belongs to owner and no non-owner template', () => {
|
|
assert.ok(policy.ROLE_RIGHTS.owner.includes('org.hire'));
|
|
for (const role of ['member', 'collaborator', 'approver']) {
|
|
assert.ok(!policy.ROLE_RIGHTS[role].includes('org.hire'), role);
|
|
}
|
|
});
|
|
|
|
test('R1 an owner can grant org.hire to a member without widening the template', () => {
|
|
const rights = policy.normalizeRights(policy.ROLE_RIGHTS.member, { add: ['org.hire'] });
|
|
assert.deepEqual(rights, ['board.read', 'org.hire', 'org.view', 'thread.read']);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, rights), true);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, ['org.hire']), false);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, ['org.hire', 'future.right']), false);
|
|
assert.throws(() => policy.normalizeRights(rights, { add: ['future.right'] }), { code: 'unknown-right' });
|
|
assert.deepEqual(policy.ROLE_RIGHTS.member, ['board.read', 'org.view', 'thread.read']);
|
|
});
|
|
|
|
test('R1 hire resolution requires org.hire while generic decision resolution keeps decide', () => {
|
|
assert.equal(policy.VERB_RIGHTS['hire.resolve.console'], 'org.hire');
|
|
assert.equal(policy.VERB_RIGHTS['decision.resolve.console'], 'decide');
|
|
});
|
|
|
|
for (const [role, rights] of Object.entries(BUNDLES)) {
|
|
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
|
|
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
|
|
});
|
|
}
|
|
|
|
test('M1 owner holds every right; only owner holds any reserved right', () => {
|
|
assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort());
|
|
for (const role of ['member', 'collaborator', 'approver']) {
|
|
for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`);
|
|
assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write'));
|
|
}
|
|
assert.ok(!policy.ROLE_RIGHTS.member.includes('decide'));
|
|
assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write'));
|
|
});
|
|
|
|
test('M1 canGrant allows only known grantable rights held by the granter', () => {
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true);
|
|
assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true);
|
|
assert.equal(policy.canGrant(['org.view'], ['thread.read']), false);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true);
|
|
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right);
|
|
}
|
|
assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false);
|
|
});
|
|
|
|
test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => {
|
|
for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) {
|
|
assert.equal(policy.canGrant(value, ['org.view']), false);
|
|
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false);
|
|
}
|
|
});
|
|
|
|
test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => {
|
|
const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']);
|
|
assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']);
|
|
assert.deepEqual(policy.normalizeRights([]), ['org.view']);
|
|
const add = Object.freeze(['task.write', 'decide', 'task.write']);
|
|
const remove = Object.freeze(['thread.read']);
|
|
assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']);
|
|
assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']);
|
|
assert.notEqual(policy.normalizeRights(rights), rights);
|
|
});
|
|
|
|
test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => {
|
|
for (const rights of [[], ['org.view']]) {
|
|
assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' });
|
|
}
|
|
});
|
|
|
|
test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => {
|
|
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
|
|
const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right';
|
|
for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) {
|
|
assert.throws(() => policy.normalizeRights(rights, options), { code }, right);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('M1 normalization refuses malformed lists, options and non-string rights', () => {
|
|
for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) {
|
|
assert.throws(() => policy.normalizeRights(value));
|
|
assert.throws(() => policy.normalizeRights([], { add: value }));
|
|
assert.throws(() => policy.normalizeRights([], { remove: value }));
|
|
}
|
|
for (const options of [null, [], 'member', 1, { typo: [] }]) {
|
|
assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' });
|
|
}
|
|
});
|
|
|
|
test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => {
|
|
assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS);
|
|
for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right));
|
|
});
|
|
|
|
test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => {
|
|
for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) {
|
|
for (const verb of verbs) {
|
|
assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`);
|
|
assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`);
|
|
assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb);
|
|
}
|
|
}
|
|
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
|
|
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
|
|
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
|
|
// T1: the definition verbs. The CLI calls them today; the Console route comes later.
|
|
'definition.import.console', 'definition.edit.console', 'definition.invent.console', 'definition.remove.console',
|
|
])].sort());
|
|
});
|
|
|
|
test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => {
|
|
for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) {
|
|
assert.equal(policy.VERB_RIGHTS[verb], null, verb);
|
|
}
|
|
for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) {
|
|
assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb);
|
|
assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false);
|
|
}
|
|
});
|
|
|
|
test('M1 unknown verbs, rights and role property names never inherit an assignment', () => {
|
|
for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) {
|
|
assert.equal(policy.VERB_RIGHTS[name], undefined);
|
|
assert.equal(policy.RIGHTS[name], undefined);
|
|
if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined);
|
|
assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' });
|
|
}
|
|
});
|
|
|
|
test('M1 only thread.write needs collaborator; every other known right needs viewer', () => {
|
|
for (const right of Object.keys(RIGHTS)) {
|
|
assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right);
|
|
}
|
|
for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' });
|
|
});
|
|
|
|
test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => {
|
|
for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) {
|
|
assert.equal(Object.getPrototypeOf(map), null);
|
|
assert.ok(Object.isFrozen(map));
|
|
assert.throws(() => { map.extra = 'decide'; }, TypeError);
|
|
}
|
|
for (const descriptor of Object.values(policy.RIGHTS)) {
|
|
assert.ok(Object.isFrozen(descriptor));
|
|
assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError);
|
|
}
|
|
for (const bundle of Object.values(policy.ROLE_RIGHTS)) {
|
|
assert.ok(Object.isFrozen(bundle));
|
|
assert.throws(() => bundle.push('decide'), TypeError);
|
|
}
|
|
assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read'])));
|
|
});
|