/** * tests/rules/chain-condition-step.test.js — ChainRule CONDITION STEP. * * A chain step of the form { rule: , conditionStep: true } is a * condition-gated hop instead of an edge traversal. It is valid only as the * FINAL step: the object is known, so the engine verifies the referenced rule * at (intermediate, object) for each current path. The DSL compiler emits * these when a chain's object-side hop references a defeasible/logical * evidence (e.g. `member_of(user,*g){ gated(g,doc) }` where gated is * `WHEN can_view(group, doc) UNLESS banned(group)`). */ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '../../src/index.js'; import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js'; import { ChainRule } from '../../src/authorization/rules/ChainRule.js'; let arbiter, evaluator, chainRule; function evalRule(userKey, objectKey, rule, options = {}) { const userId = arbiter.resolveNodeId(userKey); const objectId = arbiter.resolveNodeId(objectKey); return chainRule._evaluateRule(userId, userKey, objectId, objectKey, rule, new Set(), null, { includeMeta: true, ...options }); } const CONDITION_CONFIG = { type: 'logical', when: { intersection: { rules: [{ type: 'direct', relation: 'can_view' }], aggregator: 'min' } }, // banned(group) is unary → subject-as-object (self-edge), as the DSL emits unless: { union: { rules: [{ type: 'direct', relation: 'banned', _subjectAsObject: true }], aggregator: 'max' } } }; describe('ChainRule condition step (rule-based final hop)', () => { beforeEach(() => { arbiter = new Arbiter(); evaluator = new RuleEvaluator(arbiter); chainRule = new ChainRule(arbiter, evaluator); arbiter.addNode('user:u', 'user'); arbiter.addNode('group:g', 'group'); arbiter.addNode('doc:d', 'doc'); arbiter.setRelationConfig('can_view', { type: 'direct' }); arbiter.setRelationConfig('banned', { type: 'direct' }); }); it('grants when the condition holds at the object', () => { arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 }); arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 }); const rule = { type: 'chain', steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }] }; const res = evalRule('user:u', 'doc:d', rule); // min(member_of, can_view*(1 - banned)) = min(1.0, 0.7) = 0.7 assert.ok(Math.abs(res.possibility - 0.7) < 1e-9, `expected 0.7, got ${res.possibility} (${res.reason})`); }); it('denies when the condition is defeated at the object', () => { arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 }); arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 }); arbiter.addRelation('group:g', 'banned', 'group:g', { possibility: 1.0 }); const rule = { type: 'chain', steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }] }; const res = evalRule('user:u', 'doc:d', rule); // min(1.0, 0.7*(1 - 1.0)) = 0 assert.equal(res.possibility, 0); }); it('denies when an earlier edge is missing', () => { arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 }); const rule = { type: 'chain', steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }] }; const res = evalRule('user:u', 'doc:d', rule); assert.equal(res.possibility, 0); assert.equal(res.reason, 'no_chain_path_found'); }); it('expands an intermediate condition step (rule-based reachability)', () => { arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.addNode('group:g', 'group'); arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 }); arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.8 }); // [condition(member_of unless banned), can_view] — the condition step is // INTERMEDIATE and discovers its reachable nodes (its base relation's // neighbors from the source, filtered by its defeater). const intermediateConfig = { type: 'logical', when: { intersection: { rules: [{ type: 'direct', relation: 'member_of' }], aggregator: 'min' } }, unless: { union: { rules: [{ type: 'direct', relation: 'banned', _subjectIsObject: true }], aggregator: 'max' } } }; const rule = { type: 'chain', steps: [{ rule: intermediateConfig, conditionStep: true }, 'can_view'] }; // g not banned → reachable via condition → can_view → doc assert.ok(Math.abs(evalRule('user:u', 'doc:d', rule).possibility - 0.8) < 1e-9); // banning g filters it out of the intermediate expansion → no path arbiter.addRelation('group:g', 'banned', 'group:g', { possibility: 1.0 }); const denied = evalRule('user:u', 'doc:d', rule); assert.equal(denied.possibility, 0); }); it('combines across multiple parallel intermediates (max aggregation)', () => { arbiter.addNode('group:g2', 'group'); arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 0.5 }); arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 }); arbiter.addRelation('user:u', 'member_of', 'group:g2', { possibility: 1.0 }); arbiter.addRelation('group:g2', 'can_view', 'doc:d', { possibility: 0.8 }); const rule = { type: 'chain', steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }] }; const res = evalRule('user:u', 'doc:d', rule); // paths: min(0.5,0.7)=0.5 and min(1.0,0.8)=0.8 -> max = 0.8 assert.ok(Math.abs(res.possibility - 0.8) < 1e-9, `expected 0.8, got ${res.possibility} (${res.reason})`); }); });