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.
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
/**
|
|
* rigor/computed-rule.test.js — js-rigor property tests for ComputedRule.
|
|
*
|
|
* ComputedRule delegates to arbiter.authChecker.check(userKey, computedRelation,
|
|
* objectKey, options) and adapts the result. Properties verified:
|
|
*
|
|
* - result.possibility equals the delegated authChecker.check result.possibility
|
|
* - result.reason defaults to 'computed_delegation' if delegated has no reason
|
|
* - result.reason passes through the delegated reason when present
|
|
* - meta.ruleType='computed' and meta.computedRelation=rule.relation
|
|
* - meta.delegated=true
|
|
* - collectedValues pass through from delegated result
|
|
* - trackEvaluation=true produces a populated result.evaluation block
|
|
* - result.shape is stable across many inputs
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { ComputedRule } from '../../src/authorization/rules/ComputedRule.js';
|
|
|
|
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
|
|
|
/**
|
|
* Build an arbiter stub whose authChecker.check returns a programmable value.
|
|
* Records all calls for assertions.
|
|
*/
|
|
function makeArbiter(delegate) {
|
|
const calls = [];
|
|
const arbiter = {
|
|
authChecker: {
|
|
check(userKey, computedRelation, objectKey, options) {
|
|
calls.push({ userKey, computedRelation, objectKey, hasVisited: !!options._visited, hasCurrentRel: !!options._currentRelation });
|
|
return delegate(userKey, computedRelation, objectKey, options);
|
|
}
|
|
}
|
|
};
|
|
return { arbiter, calls };
|
|
}
|
|
|
|
describe('ComputedRule evaluation (rigor)', () => {
|
|
it('result.possibility equals delegated authChecker.check result.possibility', async () => {
|
|
async function check(userKey, computedRel, objectKey, possibility) {
|
|
const { arbiter, calls } = makeArbiter(() => ({ possibility, reliability: 1.0 }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{}
|
|
);
|
|
if (result.possibility !== possibility) {
|
|
throw new Error(`result.possibility=${result.possibility}, expected ${possibility}`);
|
|
}
|
|
// Delegation must have happened with the rule.relation as the computed relation
|
|
if (calls.length !== 1) throw new Error(`expected 1 authChecker.check call, got ${calls.length}`);
|
|
if (calls[0].userKey !== userKey) throw new Error(`userKey=${calls[0].userKey}, expected ${userKey}`);
|
|
if (calls[0].computedRelation !== computedRel) throw new Error(`computedRelation=${calls[0].computedRelation}, expected ${computedRel}`);
|
|
if (calls[0].objectKey !== objectKey) throw new Error(`objectKey=${calls[0].objectKey}, expected ${objectKey}`);
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.float({ min: 0, max: 1 })
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('possibility-passthrough', ({ 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-passthrough');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `possibility passthrough violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result.reason defaults to "computed_delegation" when delegated has no reason', async () => {
|
|
async function check(userKey, computedRel, objectKey) {
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{}
|
|
);
|
|
if (result.reason !== 'computed_delegation') {
|
|
throw new Error(`reason=${result.reason}, expected 'computed_delegation'`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('reason-default', ({ 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 === 'reason-default');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `reason default violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result.reason passes through the delegated reason when present', async () => {
|
|
async function check(userKey, computedRel, objectKey, reason) {
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, reason }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{}
|
|
);
|
|
if (result.reason !== reason) {
|
|
throw new Error(`reason=${result.reason}, expected ${reason}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(['direct_match', 'no_relation', 'inferred', 'chain_match', 'computed_delegation'])
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('reason-passthrough', ({ 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 === 'reason-passthrough');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `reason passthrough violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('meta.ruleType="computed" and meta.computedRelation=rule.relation', async () => {
|
|
async function check(userKey, computedRel, objectKey) {
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{ includeMeta: true, trackEvaluation: false }
|
|
);
|
|
if (!result.meta) throw new Error(`result.meta is missing (full result: ${JSON.stringify(result)})`);
|
|
if (result.meta.ruleType !== 'computed') {
|
|
throw new Error(`meta.ruleType=${result.meta.ruleType}, expected 'computed' (full meta: ${JSON.stringify(result.meta)})`);
|
|
}
|
|
if (result.meta.computedRelation !== computedRel) {
|
|
throw new Error(`meta.computedRelation=${result.meta.computedRelation}, expected ${computedRel}`);
|
|
}
|
|
if (result.meta.delegated !== true) {
|
|
throw new Error(`meta.delegated=${result.meta.delegated}, expected true`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('meta-contract', ({ 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 === 'meta-contract');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `meta contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('collectedValues pass through from delegated result', async () => {
|
|
async function check(userKey, computedRel, objectKey, nValues) {
|
|
const values = Array.from({ length: nValues }, (_, i) => ({
|
|
value: i + 1,
|
|
possibility: 0.5,
|
|
path: [userKey, objectKey],
|
|
source: { entityKey: userKey, relation: computedRel, step: 0 },
|
|
metadata: { timestamp: 1000, reliability: 1.0 }
|
|
}));
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, collectedValues: values }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{}
|
|
);
|
|
if (result.collectedValues.length !== nValues) {
|
|
throw new Error(`collectedValues.length=${result.collectedValues.length}, expected ${nValues}`);
|
|
}
|
|
for (let i = 0; i < nValues; i++) {
|
|
if (result.collectedValues[i].value !== values[i].value) {
|
|
throw new Error(`collectedValues[${i}].value=${result.collectedValues[i].value}, expected ${values[i].value}`);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.int(0, 5)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('collected-values-passthrough', ({ 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 === 'collected-values-passthrough');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `collected values passthrough violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('result.possibility defaults to 0 when delegated has no possibility', async () => {
|
|
async function check(userKey, computedRel, objectKey) {
|
|
const { arbiter } = makeArbiter(() => ({ reliability: 1.0 })); // no possibility
|
|
const rule = new ComputedRule(arbiter);
|
|
const result = rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
new Set(),
|
|
'unused',
|
|
{}
|
|
);
|
|
if (result.possibility !== 0) {
|
|
throw new Error(`result.possibility=${result.possibility}, expected 0 (fallback when delegated is undefined)`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('possibility-fallback-zero', ({ 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 === 'possibility-fallback-zero');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `possibility fallback violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('authChecker.check is called with the visited set and currentRelation passed through options', async () => {
|
|
async function check(userKey, computedRel, objectKey, currentRel) {
|
|
const { arbiter, calls } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
|
const rule = new ComputedRule(arbiter);
|
|
const visited = new Set([`visited:1`, `visited:2`]);
|
|
rule.evaluate(
|
|
0, userKey, 1, objectKey,
|
|
{ type: 'computed', relation: computedRel },
|
|
visited,
|
|
currentRel,
|
|
{}
|
|
);
|
|
if (calls.length !== 1) throw new Error(`expected 1 call, got ${calls.length}`);
|
|
if (!calls[0].hasVisited) throw new Error('authChecker.check did not receive options._visited');
|
|
if (!calls[0].hasCurrentRel) throw new Error('authChecker.check did not receive options._currentRelation');
|
|
return true;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS),
|
|
rigor.gen.string(1, 30),
|
|
rigor.gen.enum(RELATIONS)
|
|
)
|
|
)],
|
|
rigor.crucible([
|
|
rigor.invariant('options-passthrough', ({ 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 === 'options-passthrough');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true, `options passthrough violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|