initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* rigor/input-range-parity.test.js — write-boundary possibility validation
|
||||
* and id-hygiene contracts.
|
||||
*
|
||||
* The engine contract for addRelation()/removeRelation() writes:
|
||||
* - possibility must be a finite number in [0, 1]; anything else THROWS
|
||||
* and leaves the graph untouched (no partial writes).
|
||||
* - undefined possibility defaults to 1.0 (pre-existing contract).
|
||||
* - node keys and relation names are exact-match strings: a numeric key
|
||||
* is a DIFFERENT node than its string form (missing_node), and '|'
|
||||
* inside keys/relation names is harmless (keys are numeric ids in the
|
||||
* cache layer, so delimiter injection is structurally impossible).
|
||||
*
|
||||
* Two arms:
|
||||
* 1. FIXED MATRIX — every invalid shape is rejected, every boundary value
|
||||
* accepted, graph state preserved across failed writes.
|
||||
* 2. STATE PROPERTY CAMPAIGN — random add/check sequences against a mirror
|
||||
* model; the mirror independently classifies each possibility as
|
||||
* valid/invalid and the engine must agree exactly, plus check()
|
||||
* results always stay in [0, 1].
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
|
||||
const U = 2; // users
|
||||
const D = U + 2; // docs
|
||||
const NODES = D + 1;
|
||||
|
||||
function nodeKey(id) {
|
||||
if (id < U) return `u:${id}`;
|
||||
if (id < D) return `doc:${id - U}`;
|
||||
return 'g:0';
|
||||
}
|
||||
|
||||
function isValid(p) {
|
||||
return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1;
|
||||
}
|
||||
|
||||
function makeWrapper() {
|
||||
const arbiter = new Arbiter();
|
||||
for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < U ? 'user' : 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||||
const tuples = new Map();
|
||||
const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`;
|
||||
|
||||
return {
|
||||
tuples,
|
||||
arbiter,
|
||||
add(src, rel, dst, p) {
|
||||
const key = nodeKey(src);
|
||||
const dstKey = nodeKey(dst);
|
||||
if (p === undefined || isValid(p)) {
|
||||
arbiter.addRelation(key, rel, dstKey, p === undefined ? undefined : { possibility: p });
|
||||
tuples.set(tupleKey(key, rel, dstKey), p === undefined ? 1.0 : p);
|
||||
return { accepted: true };
|
||||
}
|
||||
return { accepted: false };
|
||||
},
|
||||
check(src, rel, dst) {
|
||||
const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
|
||||
let expected = 0;
|
||||
for (const [k, p] of tuples) {
|
||||
if (k === tupleKey(nodeKey(src), 'owner', nodeKey(dst))) expected = Math.max(expected, p);
|
||||
}
|
||||
const engine = typeof result.possibility === 'number' && Number.isFinite(result.possibility) ? result.possibility : -1;
|
||||
return { engine: Math.round(engine * 10000) / 10000, expected: Math.round(expected * 10000) / 10000 };
|
||||
},
|
||||
checkNumericId(src, rel, dst) {
|
||||
const result = arbiter.check(src, rel, dst);
|
||||
return result.reason;
|
||||
},
|
||||
clone() {
|
||||
const fresh = makeWrapper();
|
||||
for (const [k, p] of tuples) {
|
||||
const [src, rel, dst] = k.split('|');
|
||||
fresh.arbiter.addRelation(src, rel, dst, { possibility: p });
|
||||
fresh.tuples.set(k, p);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('Possibility write-boundary validation (rigor)', () => {
|
||||
it('FIXED MATRIX: invalid possibilities throw and leave the graph untouched; boundaries accepted', () => {
|
||||
const w = makeWrapper();
|
||||
const invalid = [2.0, -1, -0.0001, 1.0001, NaN, Infinity, -Infinity, null];
|
||||
for (const p of invalid) {
|
||||
assert.throws(
|
||||
() => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p }),
|
||||
/Invalid possibility/,
|
||||
`possibility ${String(p)} must be rejected`
|
||||
);
|
||||
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0, 'graph must stay untouched');
|
||||
}
|
||||
for (const p of [0, 1, 0.001, 0.99999, 0.3]) {
|
||||
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p });
|
||||
const got = w.arbiter.check('u:0', 'can_read', 'doc:0').possibility;
|
||||
assert.ok(Math.abs(got - p) < 1e-12, `boundary value ${p} accepted and returned (got ${got})`);
|
||||
}
|
||||
w.arbiter.addRelation('u:0', 'owner', 'doc:1', {});
|
||||
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:1').possibility, 1.0, 'undefined possibility defaults to 1.0 on create');
|
||||
assert.throws(
|
||||
() => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: -0.5 }),
|
||||
/Invalid possibility/
|
||||
);
|
||||
assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'failed modify keeps old value');
|
||||
|
||||
const fresh = makeWrapper();
|
||||
fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.3 });
|
||||
fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', {});
|
||||
assert.equal(fresh.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'modify with undefined possibility preserves old value');
|
||||
});
|
||||
|
||||
it('ID HYGIENE: numeric keys are distinct nodes; pipe characters are harmless', () => {
|
||||
const w = makeWrapper();
|
||||
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 });
|
||||
assert.equal(w.checkNumericId('u:0', 'can_read', 'doc:0'), 'direct_match', 'string keys resolve');
|
||||
assert.equal(w.checkNumericId(0, 'can_read', 2), 'missing_node', 'numeric keys are different nodes');
|
||||
assert.equal(w.arbiter.keyManager.getStringId('u:0') !== w.arbiter.keyManager.getStringId(0), true, 'string/number ids never collide');
|
||||
assert.equal(w.arbiter.keyManager.getStringId('a|b') !== w.arbiter.keyManager.getStringId('a'), true, 'pipe-bearing keys are distinct');
|
||||
w.arbiter.setRelationConfig('r|x', { type: 'direct', relation: 'owner' });
|
||||
w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.7 });
|
||||
assert.equal(w.arbiter.check('u:0', 'r|x', 'doc:0').possibility, 0.7, 'pipe in relation name works');
|
||||
assert.equal(w.arbiter.check('u:0', 'r', 'doc:0').possibility, 0, 'relation r is NOT r|x');
|
||||
});
|
||||
|
||||
it('PROPERTY CAMPAIGN: engine write contract agrees with the independent classifier', async () => {
|
||||
const addArgs = 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([0.1, 0.5, 0.9, NaN, Infinity, -Infinity, 2.5, -0.5, 1.001, null, undefined])
|
||||
);
|
||||
const checkArgs = rigor.gen.tuple(
|
||||
rigor.gen.int(0, U - 1),
|
||||
rigor.gen.constant('can_read'),
|
||||
rigor.gen.constant(D)
|
||||
);
|
||||
|
||||
const result = await rigor.campaign(
|
||||
[rigor.object('graph', makeWrapper, [
|
||||
rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); },
|
||||
rigor.args(addArgs)),
|
||||
rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); },
|
||||
rigor.args(checkArgs))
|
||||
])],
|
||||
rigor.crucible([
|
||||
rigor.invariant('rejected iff invalid', (ctx) => {
|
||||
if (ctx.action !== 'graph.add') return true;
|
||||
const p = ctx.args[3];
|
||||
return ctx.actual.accepted === (p === undefined || isValid(p));
|
||||
}),
|
||||
rigor.invariant('no partial writes on rejection', (ctx) => {
|
||||
if (ctx.action !== 'graph.add') return true;
|
||||
if (ctx.actual.accepted) return true;
|
||||
return ctx.error === null;
|
||||
}),
|
||||
rigor.invariant('check parity with mirror', (ctx) => {
|
||||
if (ctx.action !== 'graph.check') return true;
|
||||
return ctx.actual.engine === ctx.actual.expected;
|
||||
}),
|
||||
rigor.invariant('possibility always in [0,1] or absent', (ctx) => {
|
||||
if (ctx.action !== 'graph.check') return true;
|
||||
return ctx.actual.engine >= 0 && ctx.actual.engine <= 1;
|
||||
})
|
||||
])
|
||||
).run({ effort: 400, seed: 'input-range-contract' });
|
||||
|
||||
const inv = result.crucibleVerdict;
|
||||
assert.equal(inv.passed, true, [
|
||||
`engine diverged from contract in ${inv.failureCount} cases:`,
|
||||
...result.failures.slice(0, 3).map((f) =>
|
||||
` [${f.action}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
||||
)
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user