717ae1031e
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
178 lines
7.7 KiB
JavaScript
178 lines
7.7 KiB
JavaScript
/**
|
|
* rigor/comparator-full-path.test.js — js-rigor property tests for
|
|
* RelationalComparatorRule through the FULL check() pipeline (compiled
|
|
* evaluator, rule collector, checker wiring) — the existing
|
|
* relational-comparator-rule.test.js only exercises the rule directly.
|
|
*
|
|
* Properties verified:
|
|
*
|
|
* - COMPARISON PARITY: value comparisons through check() agree with a
|
|
* direct oracle (left > right with epsilon => high possibility +
|
|
* values_compared_comparison_true; otherwise 0 + _comparison_false).
|
|
* - VALUE FLOW: edge values reach the comparator from direct relations
|
|
* on both the user and object perspectives (evaluateFrom auto/user/object).
|
|
* - MUTATION FRESHNESS: value updates flip comparisons immediately
|
|
* (with warm caches).
|
|
* - PATH PARITY: compiled and rule-based paths agree exactly.
|
|
* - BINARY DECISION: binary allow iff normal possibility >= threshold.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { Arbiter } from '../../src/index.js';
|
|
|
|
const EPS = 1e-9;
|
|
const VALUES = [0, 10, 50, 100, 1000];
|
|
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return {
|
|
next() {
|
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildArbiter() {
|
|
const arb = new Arbiter();
|
|
arb.addNode('user:alice', 'user');
|
|
arb.addNode('doc:secret', 'doc');
|
|
arb.setRelationConfig('has_balance', { type: 'direct' });
|
|
arb.setRelationConfig('has_price', { type: 'direct' });
|
|
arb.setRelationConfig('premium', {
|
|
type: 'relational_comparator',
|
|
comparator: '>',
|
|
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
|
|
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
|
});
|
|
return arb;
|
|
}
|
|
|
|
describe('Relational comparator full-path parity (rigor)', () => {
|
|
it('COMPARISON + MUTATION PARITY through check()', async () => {
|
|
async function check({ seed }) {
|
|
const rng = mulberry32(seed);
|
|
const arb = buildArbiter();
|
|
|
|
let balance = VALUES[Math.floor(rng.next() * VALUES.length)];
|
|
let price = VALUES[Math.floor(rng.next() * VALUES.length)];
|
|
arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 });
|
|
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
|
|
|
const verify = (tag) => {
|
|
const res = arb.check('user:alice', 'premium', 'doc:secret', {});
|
|
const expected = balance > price ? 1 : 0;
|
|
if (Math.abs(res.possibility - expected) > EPS) {
|
|
fail(`${tag}: balance=${balance} price=${price} expected=${expected} got=${res.possibility} reason=${res.reason}`);
|
|
}
|
|
// Reason contract: the false outcome surfaces the comparator reason;
|
|
// the true outcome carries it inside meta.allow (outer reason is
|
|
// the generic allow_rule_matched).
|
|
if (balance > price) {
|
|
const metaRes = arb.check('user:alice', 'premium', 'doc:secret', { includeMeta: true });
|
|
if (metaRes.meta?.allow?.reason !== 'values_compared_comparison_true') {
|
|
fail(`${tag}: expected meta.allow.reason=values_compared_comparison_true, got ${metaRes.meta?.allow?.reason}`);
|
|
}
|
|
} else if (res.reason !== 'values_compared_comparison_false') {
|
|
fail(`${tag}: expected reason=values_compared_comparison_false, got ${res.reason}`);
|
|
}
|
|
// Rule-path parity
|
|
const rulePath = arb.check('user:alice', 'premium', 'doc:secret', { useCompiled: false });
|
|
if (Math.abs(rulePath.possibility - expected) > EPS) {
|
|
fail(`${tag}: rule path ${rulePath.possibility} vs expected ${expected}`);
|
|
}
|
|
// Binary decision parity
|
|
const bin = arb.check('user:alice', 'premium', 'doc:secret', { binary: true, minAllowPossibility: 0.5 });
|
|
if (bin.allow !== (expected >= 0.5)) {
|
|
fail(`${tag}: binary allow=${bin.allow} expected=${expected >= 0.5}`);
|
|
}
|
|
};
|
|
|
|
verify('initial');
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
if (rng.next() < 0.5) {
|
|
balance = VALUES[Math.floor(rng.next() * VALUES.length)];
|
|
arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 });
|
|
} else {
|
|
price = VALUES[Math.floor(rng.next() * VALUES.length)];
|
|
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
|
}
|
|
verify(`mutation ${i}`);
|
|
}
|
|
return { balance, price };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('comparator-full-path', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1200, seed: 'comparator-full-path-parity' });
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-full-path');
|
|
assert.ok(inv, 'invariant missing');
|
|
assert.equal(inv.passed, true, `comparator full-path parity violated in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('AGGREGATION: multiple value-carrying edges aggregate by max for the operand', async () => {
|
|
async function check({ seed }) {
|
|
const rng = mulberry32(seed);
|
|
const arb = buildArbiter();
|
|
arb.addNode('mid:1', 'mid');
|
|
|
|
// Two balance edges (user -> mid1 -> doc via r1), values 100 and 40
|
|
arb.setRelationConfig('r1', { type: 'direct' });
|
|
arb.addRelation('user:alice', 'r1', 'mid:1', { value: 100, possibility: 1.0 });
|
|
arb.addRelation('mid:1', 'r1', 'doc:secret', { value: 40, possibility: 1.0 });
|
|
const price = 50;
|
|
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
|
|
|
// Operand over a chain: values collected along the chain aggregate
|
|
arb.setRelationConfig('balance_chain', {
|
|
type: 'chain',
|
|
steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }]
|
|
});
|
|
arb.setRelationConfig('premium_chain', {
|
|
type: 'relational_comparator',
|
|
comparator: '>',
|
|
left: { rule: { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }] }, extractValue: true },
|
|
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
|
});
|
|
|
|
const res = arb.check('user:alice', 'premium_chain', 'doc:secret', { includeMeta: true });
|
|
// Values along the chain: 100 and 40; max aggregator -> 100 > 50 -> true
|
|
if (res.meta?.allow?.reason !== 'values_compared_comparison_true' || Math.abs(res.possibility - 1) > EPS) {
|
|
fail(`chain operand comparison: got reason=${res.reason} p=${res.possibility} allowReason=${res.meta?.allow?.reason}`);
|
|
}
|
|
return { res: res.possibility };
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check, rigor.args(
|
|
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
|
))
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('comparator-aggregation', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 500, seed: 'comparator-aggregation' });
|
|
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-aggregation');
|
|
assert.ok(inv, 'invariant missing');
|
|
assert.equal(inv.passed, true, `comparator aggregation violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|