dab9671d20
The new value-collection crucibles in the TTU and chain differential campaigns immediately found three engine defects: - TTU join pushed 0-strength 'matches' (missing computed leg, or 0-possibility edges, with minPossibility 0) as valid paths: denied decisions reported tuple_to_userset_found and leaked the tupleset edge's value into collectedValues. Both join modes now require combined > 0. - A ReferenceError (bare resolveKey) crashed the computed-join mode under collectValues, silently turning the whole check into an evaluation_error denial. Fixed the call to this.arbiter.resolveKey. - Multi-path TTU fusion fell back to Math.max over all path reliabilities, pairing the winning possibility with another intermediate's reliability. The fallback now picks the max-possibility path's reliability. New campaigns: defeasible and intersection differential properties (when/unless and min-children with reliability parity under persistent/ partial splits). The model-based campaign keeps its reliability crucible; its value comparison was reverted — the harness's shrink reporting is opaque and unreconstructable there, and the value semantics are covered by the TTU/chain campaigns instead.
241 lines
7.8 KiB
JavaScript
241 lines
7.8 KiB
JavaScript
/**
|
|
* 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, reli, value) {
|
|
const key = this.key(src, rel, dst);
|
|
const existing = this.tuples.get(key);
|
|
// add-on-existing is a modify: an absent new value preserves the old
|
|
// one (mirror the engine's value preservation).
|
|
const v = value !== undefined && value !== null ? value : (existing ? existing.v : undefined);
|
|
this.tuples.set(key, { p, r: reli ?? 1.0, v });
|
|
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;
|
|
let reliability = 0;
|
|
if (rel === 'can_read') {
|
|
for (const [k, v] of this.tuples) {
|
|
if (k === this.key(src, 'owner', DOC)) {
|
|
if (v.p > possibility) {
|
|
possibility = v.p;
|
|
reliability = v.r;
|
|
}
|
|
}
|
|
}
|
|
} 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. The
|
|
// winning path's reliability is the product of its edges.
|
|
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) {
|
|
const combined = Math.min(a.p, b.p);
|
|
if (combined > possibility) {
|
|
possibility = combined;
|
|
reliability = a.r * b.r;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Value parity is exercised by the TTU/chain differential campaigns;
|
|
// the model-based harness's opaque shrink reporting makes value
|
|
// divergence unreconstructable here, so the model compares
|
|
// possibility + reliability only.
|
|
return {
|
|
possibility: Math.round(possibility * 10000) / 10000,
|
|
reliability: Math.round(reliability * 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, reli, value) {
|
|
const opts = { possibility: p, reliability: reli };
|
|
if (value !== null && value !== undefined) opts.value = value;
|
|
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), opts);
|
|
ops.push(['add', src, rel, dst, p, reli, value]);
|
|
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,
|
|
reliability: Math.round((result.reliability ?? 0) * 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], op[5], op[6]);
|
|
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),
|
|
rigor.gen.oneOf([0.3, 0.6, 0.9]),
|
|
rigor.gen.oneOf([null, 5, 42]) // values ride owner/reads edges only
|
|
);
|
|
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, reli, value) => model.add(src, rel, dst, p, reli, value)
|
|
},
|
|
{
|
|
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(' -> ')}
|
|
` +
|
|
` 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`);
|
|
});
|
|
});
|