Files
core/tests/rigor/tuple-to-userset-rule.test.js
T
John Dvorak f9d4fbe2f0 test: seed all 98 unseeded rigor campaigns
The rigor suite had 98 campaigns drawing a random seed per process,
making the suite nondeterministic on a deterministic engine (one real
~1/15 flake already caught in tuple-to-userset-rule.test.js). Every
campaign now carries a fixed, per-test-unique seed:

- 16 files touched, 100 run() calls, all 100 seeded (2 were already done)
- seed names follow <file>-<purpose> kebab-case, unique within each file
- no other content changed (effort, invariants, assertions untouched)

Verification: full rigor suite green across 11 consecutive runs, full
suite 852/790/0. Determinism is now structural, not incidental.
2026-08-02 16:39:36 -07:00

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, 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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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', ({ error, errorMessage }) => !error && !errorMessage)
])
).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`);
});
});