/** * rigor/chain-rule.test.js — js-rigor property tests for ChainRule. * * ChainRule follows a chain of relations and collects values along the path. * Properties verified: * * - Empty steps → possibility=0, reason='no_chain_steps_defined' * - Empty graph (no relations) → possibility=0 * - 1-step chain with matching relation → possibility > 0 * - 2-step chain through intermediate node → possibility > 0 * - result.possibility ∈ [0, 1] * - bypassPLTC: true skips reachability check */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; import { ChainRule } from '../../src/authorization/rules/ChainRule.js'; describe('ChainRule evaluation (rigor)', () => { it('empty steps → possibility=0, reason=no_chain_steps_defined', async () => { async function check() { const arbiter = new Arbiter(); arbiter.addNode('user:alice', 'user'); arbiter.addNode('doc:secret', 'doc'); const rule = new ChainRule(arbiter); const userId = arbiter.resolveNodeId('user:alice'); const objectId = arbiter.resolveNodeId('doc:secret'); const result = rule._evaluateRule( userId, 'user:alice', objectId, 'doc:secret', { type: 'chain', steps: [] }, new Set(), null, { includeMeta: true, bypassPLTC: true } ); if (result.possibility !== 0) { throw new Error(`expected 0 for empty steps, got ${result.possibility}`); } if (result.reason !== 'no_chain_steps_defined') { throw new Error(`expected reason='no_chain_steps_defined', got '${result.reason}'`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('empty-steps', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 200 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'empty-steps'); assert.ok(inv); assert.equal(inv.passed, true, `empty-steps contract violated in ${inv.failureCount} cases`); }); it('result.possibility ∈ [0, 1] with various chain configurations', 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 ChainRule(arbiter); const userId = arbiter.resolveNodeId('user:alice'); const objectId = arbiter.resolveNodeId('doc:secret'); const result = rule._evaluateRule( userId, 'user:alice', objectId, 'doc:secret', { type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] }, new Set(), null, { includeMeta: true, bypassPLTC: 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([ rigor.invariant('possibility-bounded', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); 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('no matching 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 ChainRule(arbiter); const userId = arbiter.resolveNodeId('user:alice'); const objectId = arbiter.resolveNodeId('doc:secret'); const result = rule._evaluateRule( userId, 'user:alice', objectId, 'doc:secret', { type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] }, new Set(), null, { includeMeta: true, bypassPLTC: true } ); if (result.possibility !== 0) { throw new Error(`expected 0 with no relations, got ${result.possibility}`); } return result; } const report = await rigor.campaign( [rigor.fn('check', check, rigor.args())], rigor.crucible([ rigor.invariant('no-path', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 500 }); 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('1-step chain with matching relation → possibility > 0', 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 ChainRule(arbiter); const userId = arbiter.resolveNodeId('user:alice'); const objectId = arbiter.resolveNodeId('doc:secret'); const result = rule._evaluateRule( userId, 'user:alice', objectId, 'doc:secret', { type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] }, new Set(), null, { includeMeta: true, bypassPLTC: true } ); if (result.possibility <= 0) { throw new Error(`expected possibility>0 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.crucible([ rigor.invariant('one-step-pos', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'one-step-pos'); assert.ok(inv); assert.equal(inv.passed, true, `one-step-pos contract violated in ${inv.failureCount} cases`); }); it('2-step chain through intermediate → possibility > 0 (when both legs exist)', 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_of', 'team:eng', { possibility: strength1 }); arbiter.addRelation('team:eng', 'has_access', 'doc:secret', { possibility: strength2 }); const rule = new ChainRule(arbiter); const userId = arbiter.resolveNodeId('user:alice'); const objectId = arbiter.resolveNodeId('doc:secret'); const result = rule._evaluateRule( userId, 'user:alice', objectId, 'doc:secret', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'has_access', direction: 'out' } ] }, new Set(), null, { includeMeta: true, bypassPLTC: true } ); // Path exists, so possibility should be > 0 if (result.possibility <= 0) { throw new Error(`expected possibility>0 with 2-step path, got ${result.possibility}`); } // And it should be bounded by min(strength1, strength2) along the chain if (result.possibility > Math.min(strength1, strength2) + 0.01) { throw new Error(`possibility=${result.possibility} exceeds chain min(${strength1}, ${strength2})`); } 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([ rigor.invariant('two-step-chain', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 }); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'two-step-chain'); assert.ok(inv); assert.equal(inv.passed, true, `two-step-chain contract violated in ${inv.failureCount} cases`); }); });