2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* rigor/multi-hop-rule.test.js — js-rigor property tests for MultiHopRule.
|
|
|
|
|
*
|
|
|
|
|
* MultiHopRule performs BFS path-finding through a graph. Properties verified:
|
|
|
|
|
*
|
|
|
|
|
* - Missing relation → possibility=0, reason='no_relation_specified'
|
|
|
|
|
* - Empty graph (no relations of the target type) → possibility=0
|
|
|
|
|
* - Single-hop path with strength s → possibility=s (max aggregation default)
|
|
|
|
|
* - Multiple paths → max fused
|
|
|
|
|
* - result.possibility ∈ [0, 1]
|
|
|
|
|
* - relation required in rule config
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { rigor } from '@rigor/core';
|
|
|
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
|
import { MultiHopRule } from '../../src/authorization/rules/MultiHopRule.js';
|
|
|
|
|
|
|
|
|
|
describe('MultiHopRule evaluation (rigor)', () => {
|
|
|
|
|
it('missing relation in rule → possibility=0, reason=no_relation_specified', async () => {
|
|
|
|
|
async function check(relName) {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
|
|
|
const rule = new MultiHopRule(arbiter);
|
|
|
|
|
const userId = arbiter.resolveNodeId('user:alice');
|
|
|
|
|
const objectId = arbiter.resolveNodeId('doc:secret');
|
|
|
|
|
// Pass a rule with no relation field
|
|
|
|
|
const result = rule._evaluateRule(
|
|
|
|
|
userId, 'user:alice', objectId, 'doc:secret',
|
|
|
|
|
{ type: 'multi_hop', relation: relName }, // rigor may pass empty string
|
|
|
|
|
new Set(),
|
|
|
|
|
null,
|
|
|
|
|
{ includeMeta: true }
|
|
|
|
|
);
|
|
|
|
|
if (relName === '' || relName === undefined || relName === null) {
|
|
|
|
|
if (result.possibility !== 0) {
|
|
|
|
|
throw new Error(`expected possibility=0 for missing relation, got ${result.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
if (result.reason !== 'no_relation_specified') {
|
|
|
|
|
throw new Error(`expected reason='no_relation_specified', got '${result.reason}'`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[rigor.fn('check', check,
|
|
|
|
|
rigor.args(rigor.gen.string(0, 20)) // may be empty
|
|
|
|
|
)],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('missing-relation', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'multi-hop-missing-relation', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'missing-relation');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `missing-relation contract violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('result.possibility ∈ [0, 1] always (sparse graph)', async () => {
|
|
|
|
|
async function check(strength) {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
|
|
|
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
|
|
|
|
const rule = new MultiHopRule(arbiter);
|
|
|
|
|
const userId = arbiter.resolveNodeId('user:alice');
|
|
|
|
|
const objectId = arbiter.resolveNodeId('doc:secret');
|
|
|
|
|
const result = rule._evaluateRule(
|
|
|
|
|
userId, 'user:alice', objectId, 'doc:secret',
|
|
|
|
|
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
|
|
|
|
new Set(),
|
|
|
|
|
null,
|
|
|
|
|
{ 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.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('possibility-bounded', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'multi-hop-possibility-bounded', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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('single direct relation with strength s → possibility=s (max aggregation)', async () => {
|
|
|
|
|
async function check(strength) {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
|
|
|
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
|
|
|
|
const rule = new MultiHopRule(arbiter);
|
|
|
|
|
const userId = arbiter.resolveNodeId('user:alice');
|
|
|
|
|
const objectId = arbiter.resolveNodeId('doc:secret');
|
|
|
|
|
const result = rule._evaluateRule(
|
|
|
|
|
userId, 'user:alice', objectId, 'doc:secret',
|
|
|
|
|
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
|
|
|
|
new Set(),
|
|
|
|
|
null,
|
|
|
|
|
{ includeMeta: true }
|
|
|
|
|
);
|
|
|
|
|
// With max aggregation and a single path, possibility should equal strength
|
|
|
|
|
if (Math.abs(result.possibility - strength) > 0.001) {
|
|
|
|
|
throw new Error(`expected possibility=${strength}, got ${result.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[rigor.fn('check', check,
|
|
|
|
|
rigor.args(rigor.gen.float({ min: 0.01, max: 1 }))
|
|
|
|
|
)],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('single-path-strength', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'multi-hop-single-path', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'single-path-strength');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `single-path-strength contract violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('no path in graph → possibility=0', async () => {
|
|
|
|
|
async function check() {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
|
|
|
// No relations at all
|
|
|
|
|
const rule = new MultiHopRule(arbiter);
|
|
|
|
|
const userId = arbiter.resolveNodeId('user:alice');
|
|
|
|
|
const objectId = arbiter.resolveNodeId('doc:secret');
|
|
|
|
|
const result = rule._evaluateRule(
|
|
|
|
|
userId, 'user:alice', objectId, 'doc:secret',
|
|
|
|
|
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
|
|
|
|
new Set(),
|
|
|
|
|
null,
|
|
|
|
|
{ includeMeta: true }
|
|
|
|
|
);
|
|
|
|
|
if (result.possibility !== 0) {
|
|
|
|
|
throw new Error(`expected possibility=0 (no path), got ${result.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = await rigor.campaign(
|
|
|
|
|
[rigor.fn('check', check, rigor.args())],
|
|
|
|
|
rigor.crucible([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('no-path', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'multi-hop-no-path', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-path');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `no-path contract violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('2-hop path through intermediate node finds path', async () => {
|
|
|
|
|
async function check(strength1, strength2) {
|
|
|
|
|
const arbiter = new Arbiter();
|
|
|
|
|
arbiter.addNode('user:alice', 'user');
|
|
|
|
|
arbiter.addNode('team:eng', 'team');
|
|
|
|
|
arbiter.addNode('doc:secret', 'doc');
|
|
|
|
|
arbiter.addRelation('user:alice', 'member', 'team:eng', { possibility: strength1 });
|
|
|
|
|
arbiter.addRelation('team:eng', 'owner', 'doc:secret', { possibility: strength2 });
|
|
|
|
|
const rule = new MultiHopRule(arbiter);
|
|
|
|
|
const userId = arbiter.resolveNodeId('user:alice');
|
|
|
|
|
const objectId = arbiter.resolveNodeId('doc:secret');
|
|
|
|
|
// This requires different relations in the chain, but MultiHopRule uses
|
|
|
|
|
// single relation 'member' — so it can only follow that one relation type.
|
|
|
|
|
// Try with same relation 'member' instead, where team is also a doc
|
|
|
|
|
arbiter.addRelation('user:alice', 'member', 'doc:secret', { possibility: 0.5 }); // direct fallback
|
|
|
|
|
const result = rule._evaluateRule(
|
|
|
|
|
userId, 'user:alice', objectId, 'doc:secret',
|
|
|
|
|
{ type: 'multi_hop', relation: 'member', maxDepth: 3 },
|
|
|
|
|
new Set(),
|
|
|
|
|
null,
|
|
|
|
|
{ includeMeta: true }
|
|
|
|
|
);
|
|
|
|
|
// With max aggregation, possibility should be at least max(0.5, anything-from-strength1)
|
|
|
|
|
if (result.possibility <= 0) {
|
|
|
|
|
throw new Error(`expected positive possibility with path, got ${result.possibility}`);
|
|
|
|
|
}
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('multi-hop-finds-path', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'multi-hop-two-hop', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-hop-finds-path');
|
|
|
|
|
assert.ok(inv);
|
|
|
|
|
assert.equal(inv.passed, true, `multi-hop-finds-path violated in ${inv.failureCount} cases`);
|
|
|
|
|
});
|
|
|
|
|
});
|