Files
core/tests/rigor/authorization-config-consistency.test.js
T
John Dvorak 4fd4e20bd0 js-rigor: reliability flows through every rule kind; multi_hop value collection fixed
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.
2026-08-01 09:52:31 -07:00

180 lines
7.0 KiB
JavaScript

/**
* rigor/authorization-config-consistency.test.js — js-rigor property tests
* for relation-config semantics and evaluation-path consistency.
*
* Properties verified:
*
* - FAST/RULE PARITY: for a direct config, the fast path (AuthorizationChecker
* direct lookup) and the rule-evaluation path agree on possibility.
* - OVERRIDE: a direct config's `relation` override is honored by BOTH
* paths — checking `can_delete` (which checks `mfa`) succeeds iff the
* `mfa` edge exists, and fails iff it is absent.
* - OVERRIDE PARITY: the override result equals a plain direct check on
* the overridden relation itself.
* - REMEDIATION: a missing injectable source surfaces unified remediation
* naming the relation and object; a present witness produces none.
* - OVERRIDE + REMEDIATION: with an injectable overridden relation, the
* denied result carries a remediation option for that relation.
*/
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;
function fail(message) {
throw new Error(message);
}
function buildArbiter({ override = false, injectable = false, relation = 'mfa' } = {}) {
const arbiter = new Arbiter();
arbiter.addNode('user:1', 'user');
arbiter.addNode('doc:1', 'doc');
if (injectable) {
arbiter.setRelationConfig(relation, {
type: 'source',
relation,
injectable: true,
provides: 'Proof'
});
}
const directConfig = { type: 'direct' };
if (override) {
directConfig.relation = relation;
}
arbiter.setRelationConfig('can_delete', directConfig);
return arbiter;
}
describe('Authorization config consistency (rigor)', () => {
it('FAST/RULE PARITY: plain direct config agrees across both evaluation paths', async () => {
async function check(p) {
const arbiter = buildArbiter();
arbiter.addRelation('user:1', 'can_delete', 'doc:1', { possibility: p });
// Fast path: default check (direct config short-circuits)
const fast = arbiter.check('user:1', 'can_delete', 'doc:1');
// Rule path: force full rule evaluation by disabling the fast path
const rule = arbiter.check('user:1', 'can_delete', 'doc:1', { fastPath: false });
if (Math.abs(fast.possibility - p) > EPS) {
fail(`fast path: expected ${p}, got ${fast.possibility}`);
}
if (Math.abs(rule.possibility - p) > EPS) {
fail(`rule path: expected ${p}, got ${rule.possibility}`);
}
return { fast, rule };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
))
],
rigor.crucible([
rigor.invariant('path-parity', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 300, seed: 'authz-config-parity' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'path-parity');
assert.ok(inv);
assert.equal(inv.passed, true, `FAST/RULE parity violated in ${inv.failureCount} cases`);
});
it('OVERRIDE: direct config relation override is honored; absent witness denies', async () => {
async function check({ hasWitness, p }) {
const arbiter = buildArbiter({ override: true, relation: 'mfa' });
// The overridden relation itself must be evaluable for the parity check
arbiter.setRelationConfig('mfa', { type: 'direct' });
if (hasWitness) {
arbiter.addRelation('user:1', 'mfa', 'doc:1', { possibility: p });
}
const withWitness = arbiter.check('user:1', 'can_delete', 'doc:1');
if (hasWitness) {
if (Math.abs(withWitness.possibility - p) > EPS) {
fail(`override with witness: expected ${p}, got ${withWitness.possibility}`);
}
} else if (withWitness.possibility !== 0) {
fail(`override without witness must deny, got ${withWitness.possibility}`);
}
// Parity: can_delete (overriding to mfa) must equal checking mfa directly
const direct = arbiter.check('user:1', 'mfa', 'doc:1');
if (Math.abs(withWitness.possibility - direct.possibility) > EPS) {
fail(`override parity: can_delete=${withWitness.possibility} vs mfa=${direct.possibility}`);
}
return { withWitness, direct };
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({
hasWitness: rigor.gen.boolean(),
p: rigor.gen.oneOf([0.25, 0.5, 1])
})
))
],
rigor.crucible([
rigor.invariant('override-honored', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 300, seed: 'authz-config-override' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-honored');
assert.ok(inv);
assert.equal(inv.passed, true, `OVERRIDE contract violated in ${inv.failureCount} cases`);
});
it('REMEDIATION: missing injectable witness surfaces unified remediation; present witness has none', async () => {
async function check({ hasWitness }) {
const arbiter = buildArbiter({ injectable: true, relation: 'mfa' });
arbiter.setRelationConfig('can_delete', { type: 'direct', relation: 'mfa' });
if (hasWitness) {
arbiter.addRelation('user:1', 'mfa', 'doc:1', 1.0);
}
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
if (hasWitness) {
if (result.possibility !== 1) {
fail(`witness present should grant, got ${result.possibility}`);
}
if (result.remediation) {
fail(`witness present must not produce remediation, got ${JSON.stringify(result.remediation)}`);
}
} else {
if (result.possibility !== 0) {
fail(`witness missing should deny, got ${result.possibility}`);
}
const options = result.remediation?.options;
if (!Array.isArray(options) || options.length === 0) {
fail(`missing witness must produce remediation options`);
}
const mfaOption = options.find(o => o.relation === 'mfa' && o.object === 'doc:1');
if (!mfaOption) {
fail(`remediation must name relation 'mfa' and object 'doc:1', got ${JSON.stringify(options)}`);
}
}
return result;
}
const report = await rigor.campaign(
[
rigor.fn('check', check, rigor.args(
rigor.gen.object({ hasWitness: rigor.gen.boolean() })
))
],
rigor.crucible([
rigor.invariant('remediation-contract', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 300, seed: 'authz-config-remediation' , artifacts: { dir: '', persist: 'never' }});
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remediation-contract');
assert.ok(inv);
assert.equal(inv.passed, true, `REMEDIATION contract violated in ${inv.failureCount} cases`);
});
});