4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
353 lines
16 KiB
JavaScript
353 lines
16 KiB
JavaScript
/**
|
|
* 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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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-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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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 === '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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ 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`);
|
|
});
|
|
});
|