/** * rigor/cache-parity.test.js — js-rigor property tests for cache * correctness under mutation. * * Properties verified: * * - CACHE ON/OFF PARITY: two identical arbiters — one with caching * enabled, one with `disableCaching: true` — driven through IDENTICAL * random mutation sequences (adds/removes across direct, override and * chain configs). After EVERY mutation, every check must agree * EXACTLY. Any divergence means a stale direct-check, rule-result or * chain cache survived a mutation. * - TTL CONTRACT: with an injected fake clock, cached entries expire at * the configured TTL — an entry read after its TTL is reported * expired, never hit. * - OVERRIDE + CACHE: relation-override configs (can_read → viewer) * participate in invalidation — mutations on the base relation flip * cached override checks immediately (regression for the stale-grant * bug found by the model-based campaign). */ 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 POS = [0, 0.25, 0.5, 0.75, 1]; function fail(message) { throw new Error(message); } function buildArbiter({ caching }) { const arbiter = new Arbiter({ disableCaching: !caching, disableChainCaching: !caching, disableDirectCaching: !caching }); arbiter.addNode('user:alice', 'user'); arbiter.addNode('group:eng', 'group'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('viewer', { type: 'direct' }); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' } ] }); return arbiter; } const RELS = ['viewer', 'member_of']; const RELATIONS = [ ['add', 'user:alice', 'viewer', 'doc:1'], ['add', 'user:alice', 'member_of', 'group:eng'], ['add', 'group:eng', 'viewer', 'doc:1'], ['remove', 'user:alice', 'viewer', 'doc:1'], ['remove', 'user:alice', 'member_of', 'group:eng'], ['remove', 'group:eng', 'viewer', 'doc:1'] ]; function applyOp(arbiter, op, p) { const [kind, src, rel, dst] = op; if (kind === 'add') { arbiter.addRelation(src, rel, dst, { possibility: p }); } else { arbiter.removeRelation(src, rel, dst); } } function allChecks(arbiter) { const results = {}; for (const rel of ['can_read', 'can_access']) { results[rel] = arbiter.check('user:alice', rel, 'doc:1').possibility; } return results; } describe('Cache correctness under mutation (rigor)', () => { it('CACHE ON/OFF PARITY: cached and uncached arbiters never diverge through mutation sequences', async () => { async function check(ops) { const cached = buildArbiter({ caching: true }); const uncached = buildArbiter({ caching: false }); for (const [kind, src, rel, dst, p] of ops) { applyOp(cached, [kind, src, rel, dst], p); applyOp(uncached, [kind, src, rel, dst], p); const c = allChecks(cached); const u = allChecks(uncached); for (const rel of Object.keys(c)) { if (Math.abs(c[rel] - u[rel]) > EPS) { fail(`diverged on ${rel} after ${kind}(${src},${rel},${dst},${p}): cached=${c[rel]}, uncached=${u[rel]}`); } } } return { ops: ops.length }; } const opGen = rigor.gen.array( rigor.gen.tuple( rigor.gen.oneOf([0, 1, 2, 3, 4, 5]), // index into RELATIONS rigor.gen.oneOf(POS) ), 1, 12 ).map((pairs) => pairs.map(([idx, p]) => [...RELATIONS[idx], p])); const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args(opGen)) ], rigor.crucible([ rigor.invariant('cache-parity', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 600, seed: 'cache-onoff-parity' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cache-parity'); assert.ok(inv); assert.equal(inv.passed, true, `CACHE PARITY violated in ${inv.failureCount} cases`); }); it('OVERRIDE + CACHE: base-relation mutations immediately flip cached override checks', async () => { async function check({ p1, p2 }) { const arbiter = buildArbiter({ caching: true }); // Warm the override-path cache with a grant arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p1 }); const granted = arbiter.check('user:alice', 'can_read', 'doc:1'); if (Math.abs(granted.possibility - p1) > EPS) { fail(`setup: expected ${p1}, got ${granted.possibility}`); } // Mutate the BASE relation — the cached override check must flip NOW arbiter.removeRelation('user:alice', 'viewer', 'doc:1'); const revoked = arbiter.check('user:alice', 'can_read', 'doc:1'); if (revoked.possibility !== 0) { fail(`override grant survived base removal: ${revoked.possibility}`); } // Re-add with a different possibility — must flip again immediately arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p2 }); const regranted = arbiter.check('user:alice', 'can_read', 'doc:1'); if (Math.abs(regranted.possibility - p2) > EPS) { fail(`override grant did not update to ${p2}: ${regranted.possibility}`); } return { granted, revoked, regranted }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ p1: rigor.gen.oneOf(POS), p2: rigor.gen.oneOf(POS) }) )) ], rigor.crucible([ rigor.invariant('override-cache-fresh', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 400, seed: 'cache-override-freshness' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-cache-fresh'); assert.ok(inv); assert.equal(inv.passed, true, `OVERRIDE CACHE violated in ${inv.failureCount} cases`); }); it('TTL CONTRACT: injected clock reports entries expired after the TTL window', async () => { async function check({ ttl, delay }) { const arbiter = new Arbiter({ directCheckCacheTTL: ttl }); arbiter.addNode('user:alice', 'user'); arbiter.addNode('doc:1', 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct' }); arbiter.addRelation('user:alice', 'can_read', 'doc:1', { possibility: 1 }); let now = 1000; arbiter.decisionCache.clock = () => now; arbiter.check('user:alice', 'can_read', 'doc:1'); now += delay; const cache = arbiter.decisionCache; const key = arbiter.authChecker._getDirectCheckCacheKey('user:alice', 'can_read', 'doc:1'); const [result, status] = cache.peekDirect(key); const expectedStatus = delay >= ttl ? 'expired' : 'hit'; if (status !== expectedStatus) { fail(`ttl=${ttl}, delay=${delay}: expected '${expectedStatus}', got '${status}'`); } if (expectedStatus === 'hit' && result?.possibility !== 1) { fail(`hit entry lost its result: ${JSON.stringify(result)}`); } return status; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ ttl: rigor.gen.oneOf([100, 500, 1000]), delay: rigor.gen.oneOf([0, 50, 100, 400, 600, 1500]) }) )) ], rigor.crucible([ rigor.invariant('ttl-contract', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 300, seed: 'cache-ttl-contract' , artifacts: { dir: '', persist: 'never' }}); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttl-contract'); assert.ok(inv); assert.equal(inv.passed, true, `TTL CONTRACT violated in ${inv.failureCount} cases`); }); });