/** * 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({ seed: 'computed-rule-possibility-passthrough', 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({ seed: 'computed-rule-reason-default', 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({ seed: 'computed-rule-reason-passthrough', 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({ seed: 'computed-rule-meta-contract', 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({ seed: 'computed-rule-collected-values', 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({ seed: 'computed-rule-possibility-fallback', 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({ seed: 'computed-rule-options-passthrough', 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`); }); });