/** * rigor/parent-rule.test.js — js-rigor property tests for ParentRule. * * ParentRule grants access via parent-child relationships. The user is checked * against the target relation on each parent of the object, then OWA-fused. * Properties verified: * * - No parents → possibility=0, reason='no_parent_relationship_path_above_threshold' * - One parent with direct access at strength s → possibility=s (or 0 if below threshold) * - Multiple parents → fused via OWA aggregator (default 'max') * - Cycle (parentKey === userKey) → possibility=0, reason='cycle' * - parentRelation defaults to 'parent' if not specified * - reverse=true flips parent lookup direction * - threshold cutoff: possibilities below minPossibility are dropped pre-fusion */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { ParentRule } from '../../src/authorization/rules/ParentRule.js'; const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent']; /** * Build an arbiter stub whose relationManager.getRelationsFromSrc / * getRelationsToDst return relations from a static table, and * arbiter.indices.getDirectRelation looks up direct edges. * parents: Map> * directEdges: Map<"src|rel|dst", {possibility}> * keyMap: Map */ function makeArbiter({ parents, directEdges, keyMap }) { return { relationManager: { getRelationsFromSrc(srcId, relName) { if (relName !== 'parent') return []; // For 'parent', parents[srcId] lists relationships from src return parents.get(srcId) ?? []; }, getRelationsToDst(dstId, relName) { if (relName !== 'parent') return []; // For 'parent', parents of dst = relations where dst === src (parent->child) // Wait, actually the convention is: 'parent' relation means src is the parent // of dst. So "get parents of dstId" = relations where dstId === dst. return (parents.get(dstId) ?? []).map(r => ({ ...r, _dstRel: true })); } }, indices: { getDirectRelation(srcId, rel, dstId) { const key = `${srcId}|${rel}|${dstId}`; return directEdges.get(key) ?? null; } }, resolveKey(nodeId) { return keyMap.get(nodeId) ?? null; } }; } describe('ParentRule evaluation (rigor)', () => { it('no parents → possibility=0, reason=no_parent_relationship_path_above_threshold', async () => { async function check(userKey, objectKey) { const arbiter = makeArbiter({ parents: new Map(), directEdges: new Map(), keyMap: new Map() }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 0, userKey, 1, objectKey, { type: 'parent' }, new Set(), 'owner', {} ); if (result.possibility !== 0) { throw new Error(`possibility=${result.possibility}, expected 0 (no parents)`); } if (result.reason !== 'no_parent_relationship_path_above_threshold') { throw new Error(`reason=${result.reason}, expected 'no_parent_relationship_path_above_threshold'`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.string(1, 30), rigor.gen.string(1, 30) ) )], rigor.crucible([ rigor.invariant('no-parents', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-no-parents', effort: 500 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-parents'); assert.ok(inv); assert.equal(inv.passed, true, `no-parents contract violated in ${inv.failureCount} cases`); }); it('one parent with direct access at strength s → possibility=s (with threshold 0)', async () => { async function check(parentObjectId, strength) { // The object (id=99) has parent=10. userKey='u1', parentKey='p10', userId=1, parentId=10. // The user has direct edge to parent at the target relation with possibility=strength. const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]); const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]); const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'parent', parentRelation: 'parent', relation: 'owner' }, new Set(), 'owner', { includeMeta: true } ); // Possibility should be the direct edge strength (since threshold=0) if (result.possibility !== strength) { throw new Error(`possibility=${result.possibility}, expected ${strength} (strength of direct edge)`); } if (result.reason !== 'parent_relationship_path_found') { throw new Error(`reason=${result.reason}, expected 'parent_relationship_path_found'`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.int(0, 100), rigor.gen.float({ min: 0.01, max: 1 }) ) )], rigor.crucible([ rigor.invariant('one-parent-strength', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-one-parent', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'one-parent-strength'); assert.ok(inv); assert.equal(inv.passed, true, `one-parent-strength contract violated in ${inv.failureCount} cases`); }); it('threshold cutoff: possibilities below minPossibility drop to 0', async () => { async function check(strength, threshold) { const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]); const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]); const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'parent', parentRelation: 'parent', relation: 'owner' }, new Set(), 'owner', { minPossibility: threshold } ); if (strength >= threshold) { if (result.possibility !== strength) { throw new Error(`strength=${strength} >= threshold=${threshold}: expected possibility=${strength}, got ${result.possibility}`); } } else { if (result.possibility !== 0) { throw new Error(`strength=${strength} < threshold=${threshold}: expected possibility=0, got ${result.possibility}`); } } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.float({ min: 0.0, max: 1.0 }), rigor.gen.float({ min: 0.0, max: 1.0 }) ) )], rigor.crucible([ rigor.invariant('threshold-cutoff', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-threshold', effort: 1500 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'threshold-cutoff'); assert.ok(inv); assert.equal(inv.passed, true, `threshold-cutoff contract violated in ${inv.failureCount} cases`); }); it('cycle: parentKey === userKey → possibility=0, reason=cycle', async () => { async function check() { // Object 99 has parent = user 1. User 1 IS the parent of itself. const parents = new Map([[99, [{ src: 1, rel: 'parent', dst: 99, possibility: 1 }]]]); const directEdges = new Map(); const keyMap = new Map([[1, 'u1'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'parent', parentRelation: 'parent', relation: 'owner' }, new Set(), 'owner', {} ); if (result.possibility !== 0) { throw new Error(`cycle should yield possibility=0, got ${result.possibility}`); } if (result.reason !== 'cycle') { throw new Error(`cycle reason=${result.reason}, expected 'cycle'`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('cycle-detection', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-cycle', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-detection'); assert.ok(inv); assert.equal(inv.passed, true, `cycle-detection violated in ${inv.failureCount} cases`); }); it('multiple parents → fused via max aggregator (default)', async () => { async function check(strength1, strength2) { // Object 99 has two parents: 10 and 11. User has direct edges to both. const parents = new Map([[99, [ { src: 10, rel: 'parent', dst: 99, possibility: 1 }, { src: 11, rel: 'parent', dst: 99, possibility: 1 } ]]]); const directEdges = new Map([ [`1|owner|10`, { possibility: strength1 }], [`1|owner|11`, { possibility: strength2 }] ]); const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'parent', parentRelation: 'parent', relation: 'owner' }, new Set(), 'owner', {} ); const expected = Math.max(strength1, strength2); if (result.possibility !== expected) { throw new Error(`max aggregator: expected ${expected}, got ${result.possibility} (strengths ${strength1}, ${strength2})`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.float({ min: 0.01, max: 1 }), rigor.gen.float({ min: 0.01, max: 1 }) ) )], rigor.crucible([ rigor.invariant('multi-parent-max', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-multi-parent', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-parent-max'); assert.ok(inv); assert.equal(inv.passed, true, `multi-parent-max contract violated in ${inv.failureCount} cases`); }); it('parentRelation defaults to "parent" when not specified in rule', async () => { // Two cases: rule without parentRelation → looks up 'parent'. // rule with parentRelation='other' → looks up 'other' (and gets nothing here). async function check(useOther) { const parents = new Map(); // no parents of either kind const directEdges = new Map(); const keyMap = new Map([[1, 'u1'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const ruleConfig = useOther ? { type: 'parent', parentRelation: 'other', relation: 'owner' } : { type: 'parent', relation: 'owner' }; const result = rule.evaluate( 1, 'u1', 99, 'o99', ruleConfig, new Set(), 'owner', {} ); // Both should return possibility=0 (no parents) if (result.possibility !== 0) { throw new Error(`expected 0, got ${result.possibility}`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args(rigor.gen.boolean()))], rigor.crucible([ rigor.invariant('parent-relation-default', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-default-relation', effort: 200 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-relation-default'); assert.ok(inv); assert.equal(inv.passed, true, `parent-relation-default contract violated in ${inv.failureCount} cases`); }); it('result.possibility ∈ [0, 1] always', async () => { async function check(strength1, strength2) { const parents = new Map([[99, [ { src: 10, rel: 'parent', dst: 99, possibility: 1 }, { src: 11, rel: 'parent', dst: 99, possibility: 1 } ]]]); const directEdges = new Map([ [`1|owner|10`, { possibility: strength1 }], [`1|owner|11`, { possibility: strength2 }] ]); const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]); const arbiter = makeArbiter({ parents, directEdges, keyMap }); const rule = new ParentRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'parent', parentRelation: 'parent', relation: 'owner' }, new Set(), 'owner', {} ); if (result.possibility < 0 || result.possibility > 1) { throw new Error(`possibility=${result.possibility} outside [0,1]`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args( rigor.gen.float({ min: 0, max: 1 }), rigor.gen.float({ min: 0, max: 1 }) ) )], rigor.crucible([ rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ seed: 'parent-rule-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-bounded'); assert.ok(inv); assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`); }); });