/** * 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', ({ actual }) => actual !== undefined) ]) ).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', ({ actual }) => actual !== undefined) ]) ).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', ({ actual }) => actual !== undefined) ]) ).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`); }); });