/** * rigor/tuple-to-userset-rule.test.js — js-rigor property tests for TupleToUsersetRule. * * TupleToUsersetRule grants access via two-step pattern: object has tupleset * relation to an intermediate, user has computed relation to that intermediate. * Properties verified in the 'direct join' mode (computedRelation.type='direct'): * * - No tuples → possibility=0, reason='no_valid_intermediate_paths' * - One tuple + one matching direct edge → possibility = min(tuplePoss, edgePoss) * - Multiple tuples → max fused (default OWA weights [1,0,0,...]) * - Cycle detection → reason='cycle', possibility=0 * - reverse=true routes the lookup via the user side, not the object side * - earlyExitThreshold triggers early return when a path exceeds it * - result.possibility ∈ [0, 1] always */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { TupleToUsersetRule } from '../../src/authorization/rules/TupleToUsersetRule.js'; const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent']; /** * Build an arbiter stub. relationManager.getRelationsFromSrc/getRelationsToDst * return relations from a static table. relationConfigs.get returns * { type: 'direct' } for the computedRelation to enable useDirectJoin branch. * Also exposes a no-op authChecker.check for any fallback path. */ function makeArbiter({ tuplesetRels, directEdges, keyMap, computedRelation = 'member' }) { const relationConfigs = new Map([ ['direct', { type: 'direct' }], [computedRelation, { type: 'direct' }] // enables useDirectJoin for this computed relation ]); return { relationManager: { shouldUseRelationGraphTraversal() { return false; }, getDirectRelation(srcId, rel, dstId) { return directEdges.get(`${srcId}|${rel}|${dstId}`) ?? null; }, getRelationsFromSrc(srcId, relName) { return tuplesetRels.get(`${srcId}|${relName}`) ?? []; }, getRelationsToDst(dstId, relName) { return tuplesetRels.get(`_toDst|${dstId}|${relName}`) ?? []; } }, relationConfigs, keyManager: { _getRelationId(name) { return `__relId:${name}`; } }, resolveKey(nodeId) { return keyMap.get(nodeId) ?? null; }, resolveNodeId(key) { return keyMap.get(`_rev:${key}`) ?? null; }, // authChecker.check is unreachable in useDirectJoin mode, but stub it for safety authChecker: { check() { return { possibility: 0, reliability: 1.0, reason: 'no_authChecker' }; } } }; } describe('TupleToUsersetRule evaluation (rigor)', () => { it('no tuples → possibility=0, reason=no_valid_intermediate_paths', async () => { async function check(userKey, objectKey) { const arbiter = makeArbiter({ tuplesetRels: new Map(), directEdges: new Map(), keyMap: new Map([[1, 'u1'], [99, 'o99']]) }); const rule = new TupleToUsersetRule(arbiter); const result = rule.evaluate( 1, userKey, 99, objectKey, { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, new Set(), 'whatever', { includeMeta: true } ); if (result.possibility !== 0) { throw new Error(`possibility=${result.possibility}, expected 0`); } if (result.reason !== 'no_valid_intermediate_paths') { throw new Error(`reason=${result.reason}, expected 'no_valid_intermediate_paths'`); } 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-tuples', ({ actual }) => actual !== undefined) ]) ).run({ effort: 500, seed: 'ttu-no-tuples', artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-tuples'); assert.ok(inv); assert.equal(inv.passed, true, `no-tuples contract violated in ${inv.failureCount} cases`); }); it('one tuple + matching direct edge → possibility = min(tuplePoss, edgePoss)', async () => { async function check(tuplePoss, edgePoss) { // Object 99 has tuple owner → 50. User 1 has direct edge member → 50. const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]); // For useDirectJoin mode, computedEdges come from getRelationsFromSrc(userId, computedRelation). // The direct edge between user and intermediate must be present there, not just in directEdges. const directEdges = new Map(); const allRels = new Map([ [`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]], [`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: edgePoss }]] ]); const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]); const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap }); const rule = new TupleToUsersetRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, new Set(), 'whatever', { includeMeta: true } ); const expected = Math.min(tuplePoss, edgePoss); if (result.possibility !== expected) { throw new Error(`possibility=${result.possibility}, expected ${expected} (min of ${tuplePoss}, ${edgePoss})`); } if (result.reason !== 'tuple_to_userset_found') { throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`); } 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('min-fusion', ({ actual }) => actual !== undefined) ]) ).run({ effort: 800, seed: 'ttu-min-fusion', artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'min-fusion'); assert.ok(inv); assert.equal(inv.passed, true, `min-fusion contract violated in ${inv.failureCount} cases`); }); it('multiple tuples → fused via max (default OWA weights)', async () => { async function check(tuplePoss1, tuplePoss2, edgePoss1, edgePoss2) { // Object 99 has two tuples: owner → 50, owner → 51. User has matching edges. const allRels = new Map([ [`99|owner`, [ { src: 99, rel: 'owner', dst: 50, possibility: tuplePoss1 }, { src: 99, rel: 'owner', dst: 51, possibility: tuplePoss2 } ]], [`1|member`, [ { src: 1, rel: 'member', dst: 50, possibility: edgePoss1 }, { src: 1, rel: 'member', dst: 51, possibility: edgePoss2 } ]] ]); const directEdges = new Map(); const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51']]); const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap }); const rule = new TupleToUsersetRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, new Set(), 'whatever', { includeMeta: true } ); const path1 = Math.min(tuplePoss1, edgePoss1); const path2 = Math.min(tuplePoss2, edgePoss2); const expected = Math.max(path1, path2); if (result.possibility !== expected) { throw new Error(`possibility=${result.possibility}, expected ${expected} (max of ${path1}, ${path2})`); } 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.gen.float({ min: 0.01, max: 1 }), rigor.gen.float({ min: 0.01, max: 1 }) ) )], rigor.crucible([ rigor.invariant('multi-tuple-max', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'ttu-multi-tuple-max', 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 === 'multi-tuple-max'); assert.ok(inv); assert.equal(inv.passed, true, `multi-tuple-max contract violated in ${inv.failureCount} cases`); }); it('result.possibility ∈ [0, 1] always', async () => { async function check(tuplePoss, edgePoss) { const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]); const directEdges = new Map([[`1|member|50`, { possibility: edgePoss }]]); const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]); const arbiter = makeArbiter({ tuplesetRels, directEdges, keyMap }); const rule = new TupleToUsersetRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, new Set(), 'whatever', { includeMeta: true } ); 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'ttu-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`); }); it('early exit: high-strength path triggers early_return reason', async () => { async function check() { // 3 tuples, one with very high strength (triggers early exit at threshold=0.95) const allRels = new Map([ [`99|owner`, [ { src: 99, rel: 'owner', dst: 50, possibility: 0.99 }, // high — should trigger { src: 99, rel: 'owner', dst: 51, possibility: 0.5 }, { src: 99, rel: 'owner', dst: 52, possibility: 0.3 } ]], [`1|member`, [ { src: 1, rel: 'member', dst: 50, possibility: 1.0 }, // combined = min(0.99, 1.0) = 0.99 { src: 1, rel: 'member', dst: 51, possibility: 0.4 }, { src: 1, rel: 'member', dst: 52, possibility: 0.2 } ]] ]); const directEdges = new Map(); const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51'], [52, 'i52']]); const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap }); const rule = new TupleToUsersetRule(arbiter); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, new Set(), 'whatever', { includeMeta: true } ); // The early exit returns possibility=0.99 (the combined path that triggered it) if (result.possibility < 0.95) { throw new Error(`early exit should return high-strength path; got ${result.possibility}`); } // The reason stays 'tuple_to_userset_found' for a single-path early exit — // the 'early_exit_direct_path' string is only set in evaluationMeta.evaluationType. if (result.reason !== 'tuple_to_userset_found') { throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`); } // But the meta.evaluation.evaluationType should signal early_exit_direct_path const evalType = result.meta?.evaluation?.evaluationType; if (evalType !== 'early_exit_direct_path') { throw new Error(`meta.evaluation.evaluationType=${evalType}, expected 'early_exit_direct_path'`); } // And the performance stats should show earlyExits++ if (rule.performanceStats.earlyExits !== 1) { throw new Error(`rule.performanceStats.earlyExits=${rule.performanceStats.earlyExits}, expected 1`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('early-exit', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'ttu-early-exit', 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 === 'early-exit'); assert.ok(inv); assert.equal(inv.passed, true, `early-exit violated in ${inv.failureCount} cases`); }); it('cycle detection: visited set causes reason=cycle', async () => { async function check() { // For cycle detection to fire, we need joinMode === 'computed', which // requires computedEdges.length < tuples.length. With 1 tuple and 1 // computed edge, computedEdges.length === 1, tuples.length === 1, so // joinMode === 'tuples' and the cycle check on line 275-283 runs. // Set up: 1 tuple from object → 50, 1 computed edge user → 50, both // populated. The visited set contains the cycle key, so when the // tuples-loop processes intermediate 50, it sees the cycle. const allRels = new Map([ [`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: 0.9 }]], [`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }]] ]); const directEdges = new Map(); const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]); const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap }); const rule = new TupleToUsersetRule(arbiter); // Pre-populate visited with the cycle key — computedRelationId is "__relId:member" const visited = new Set([`1|__relId:member|50`]); const result = rule.evaluate( 1, 'u1', 99, 'o99', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' }, visited, 'whatever', { includeMeta: true } ); // Either reason='cycle' directly, OR the path possibility becomes 0 because // the cycle-detected edge yields res.possibility=0. // Looking at production: when res.reason='cycle', reasons array contains 'cycle', // and at end of _buildFinalResult: reason = reasons.includes('cycle') ? 'cycle' : ... // So result.reason SHOULD be 'cycle'. 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', ({ actual }) => actual !== undefined) ]) ).run({ seed: 'ttu-cycle-detection', 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`); }); });