/** * rigor/model-based-graph.test.js — model-based testing of the * authorization graph via rigor.model.check. * * A reference model of the graph state (relation tuples with last-write-wins * dedup) is driven through RANDOM operation sequences alongside a real * Arbiter. Every check() command must agree between model and engine — * across arbitrary interleavings of adds, removes and queries. This catches * index desync, stale caches and mutation bugs that single-step tests miss. * * Model semantics (mirrors the engine): * - addRelation: (src, rel, dst) is unique — a re-add REPLACES the * possibility (last-write-wins). * - removeRelation: no-op when the tuple is absent. * - check can_read: max over (user, owner, doc) tuple possibilities. * - check can_access: max over intermediate mids of * min((user, member_of, mid), (mid, reads, doc)). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const USERS = 2; const MIDS = 2; const DOC = USERS + MIDS; // node id of the object const NODES = DOC + 1; const POS = [0, 0.25, 0.5, 0.75, 1]; const EPS = 1e-9; function nodeKey(id) { if (id < USERS) return `u:${id}`; if (id < USERS + MIDS) return `m:${id - USERS}`; return 'doc:0'; } /** * Reference model state: plain tuple map + pure check computation. * clone() is used by the runner to isolate sequences. */ function makeModel() { return { tuples: new Map(), key(src, rel, dst) { return `${src}|${rel}|${dst}`; }, clone() { const copy = { ...this, tuples: new Map(this.tuples) }; copy.clone = this.clone; copy.key = this.key; copy.add = this.add; copy.remove = this.remove; copy.check = this.check; return copy; }, add(src, rel, dst, p) { this.tuples.set(this.key(src, rel, dst), p); return { ok: true }; }, remove(src, rel, dst) { this.tuples.delete(this.key(src, rel, dst)); return { ok: true }; }, check(src, rel, dst) { let possibility = 0; if (rel === 'can_read') { for (const [k, p] of this.tuples) { if (k === this.key(src, 'owner', DOC)) possibility = Math.max(possibility, p); } } else if (rel === 'can_access') { // The chain traverses member_of from ANY node, then reads into the // object from any reached node — mirror the engine exactly. for (let m = 0; m < NODES; m++) { const a = this.tuples.get(this.key(src, 'member_of', m)); const b = this.tuples.get(this.key(m, 'reads', DOC)); if (a !== undefined && b !== undefined) { possibility = Math.max(possibility, Math.min(a, b)); } } } return { possibility: Math.round(possibility * 10000) / 10000 }; } }; } /** * SUT wrapper: a real Arbiter with ops replay for per-sequence cloning. */ function makeSut() { const arbiter = new Arbiter(); for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i === DOC ? 'doc' : i < USERS ? 'user' : 'group'); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); arbiter.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); const ops = []; return { ops, addRelation(src, rel, dst, p) { arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p }); ops.push(['add', src, rel, dst, p]); return { ok: true }; }, removeRelation(src, rel, dst) { arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst)); ops.push(['remove', src, rel, dst]); return { ok: true }; }, check(src, rel, dst) { const result = arbiter.check(nodeKey(src), rel, nodeKey(dst)); return { possibility: Math.round(result.possibility * 10000) / 10000 }; }, clone() { const fresh = makeSut(); for (const op of ops) { if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4]); else fresh.removeRelation(op[1], op[2], op[3]); } return fresh; } }; } const tupleArgs = rigor.gen.tuple( rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf(POS) ); const removeArgs = rigor.gen.tuple( rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1) ); const checkArgs = rigor.gen.tuple( rigor.gen.int(0, USERS - 1), rigor.gen.enum(['can_read', 'can_access']), rigor.gen.constant(DOC) ); const OPERATIONS = [ { name: 'addRelation', args: tupleArgs, run: (model, src, rel, dst, p) => model.add(src, rel, dst, p) }, { name: 'removeRelation', args: removeArgs, run: (model, src, rel, dst) => model.remove(src, rel, dst) }, { name: 'check', args: checkArgs, run: (model, src, rel, dst) => model.check(src, rel, dst) } ]; describe('Model-based authorization graph (rigor.model.check)', () => { it('arbitrary add/remove/check sequences keep the engine in sync with the reference model', () => { const result = rigor.model.check( 'graph-sync', makeModel(), makeSut(), { operations: OPERATIONS, effort: 300, maxSequenceLength: 24, seed: 'model-graph-sync' } ); assert.equal(result.passed, true, [ `engine diverged from model in ${result.failures.length} sequences:`, ...result.failures.slice(0, 3).map((f) => ` [${f.commandIndex}] ${f.sequence.map((c) => `${c.name}(${JSON.stringify(c.args)})`).join(' → ')}\n` + ` expected=${JSON.stringify(f.expected)} actual=${JSON.stringify(f.actual)}` ) ].join('\n')); }); it('check results stay in [0, 1] and are deterministic under repeated queries', () => { const result = rigor.model.check( 'graph-bounds', makeModel(), makeSut(), { operations: OPERATIONS, effort: 100, maxSequenceLength: 16, seed: 'model-graph-bounds', invariants: [ (model) => { // Model-side invariant: every stored possibility is within [0,1] for (const p of model.tuples.values()) { if (p < 0 || p > 1) return false; } return true; } ] } ); assert.equal(result.passed, true, `bounds invariant violated: ${result.failures.length} failures`); }); });