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,188 @@
|
||||
/**
|
||||
* rigor/batch-loading-parity.test.js — js-rigor property tests for
|
||||
* batch construction consistency.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - BATCH PARITY: the same random graph loaded via addRelationsBatch
|
||||
* answers check() IDENTICALLY to the same graph loaded relation by
|
||||
* relation (both for direct and chain configs).
|
||||
* - BATCH DEDUP: duplicate tuples inside a batch honor last-write-wins
|
||||
* exactly like sequential re-adds (the final possibility is the last
|
||||
* one, regardless of order).
|
||||
* - BATCH + MUTATION: after batch loading, subsequent single mutations
|
||||
* (add/remove) behave exactly as on the sequentially-built arbiter.
|
||||
*/
|
||||
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 buildBase(users, mids) {
|
||||
const arbiter = new Arbiter({ fastConstructionMode: true });
|
||||
for (let i = 0; i < users; i++) arbiter.addNode(`user:${i}`, 'user');
|
||||
for (let i = 0; i < mids; i++) arbiter.addNode(`mid:${i}`, 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { 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;
|
||||
}
|
||||
|
||||
function randomEdges(users, mids) {
|
||||
const edges = [];
|
||||
const userKeys = Array.from({ length: users }, (_, i) => `user:${i}`);
|
||||
const midKeys = Array.from({ length: mids }, (_, i) => `mid:${i}`);
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const count = Math.max(2, users + mids);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const kind = Math.floor(Math.random() * 3);
|
||||
if (kind === 0) {
|
||||
edges.push({ srcKey: pick(userKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
|
||||
} else if (kind === 1 && mids > 0) {
|
||||
edges.push({ srcKey: pick(userKeys), relation: 'member_of', dstKey: pick(midKeys), options: { possibility: pick(POS) } });
|
||||
} else if (mids > 0) {
|
||||
edges.push({ srcKey: pick(midKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
|
||||
}
|
||||
}
|
||||
// Fast-construction sequential adds skip duplicate detection (bulk-loading
|
||||
// contract: callers supply distinct tuples). Dedup so both loading paths
|
||||
// see identical state — last-write-wins on the tuple.
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const e of edges) {
|
||||
const key = `${e.srcKey}|${e.relation}|${e.dstKey}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(e);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function applyEdgesSequential(arbiter, edges) {
|
||||
for (const e of edges) {
|
||||
arbiter.addRelation(e.srcKey, e.relation, e.dstKey, e.options);
|
||||
}
|
||||
}
|
||||
|
||||
function allChecks(arbiter, users) {
|
||||
const results = {};
|
||||
for (let i = 0; i < users; i++) {
|
||||
results[`u${i}`] = {
|
||||
read: arbiter.check(`user:${i}`, 'can_read', 'doc:1').possibility,
|
||||
access: arbiter.check(`user:${i}`, 'can_access', 'doc:1').possibility
|
||||
};
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('Batch loading consistency (rigor)', () => {
|
||||
it('BATCH PARITY: batch-loaded graphs answer checks identically to sequential loading', async () => {
|
||||
async function check(seedCase) {
|
||||
const { users, mids, includeDupes } = seedCase;
|
||||
let edges = randomEdges(users, mids);
|
||||
if (includeDupes && edges.length > 0) {
|
||||
// Duplicate one edge with a different possibility (last-write-wins)
|
||||
const dup = { ...edges[0] };
|
||||
dup.options = { possibility: POS[Math.floor(Math.random() * POS.length)] };
|
||||
edges = [...edges, dup];
|
||||
}
|
||||
|
||||
const batched = buildBase(users, mids);
|
||||
batched.relationManager.addRelationsBatch(edges);
|
||||
|
||||
const sequential = buildBase(users, mids);
|
||||
applyEdgesSequential(sequential, edges);
|
||||
|
||||
const b = allChecks(batched, users);
|
||||
const s = allChecks(sequential, users);
|
||||
for (const key of Object.keys(b)) {
|
||||
if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
|
||||
fail(`batch parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
|
||||
}
|
||||
}
|
||||
return { edges: edges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
users: rigor.gen.int(1, 4),
|
||||
mids: rigor.gen.int(0, 4),
|
||||
includeDupes: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('batch-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'batch-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `BATCH PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('BATCH + MUTATION: post-batch mutations behave like post-sequential mutations', async () => {
|
||||
async function check(seedCase) {
|
||||
const { users, mids, removeRel } = seedCase;
|
||||
const edges = randomEdges(users, mids);
|
||||
|
||||
const batched = buildBase(users, mids);
|
||||
batched.relationManager.addRelationsBatch(edges);
|
||||
const sequential = buildBase(users, mids);
|
||||
applyEdgesSequential(sequential, edges);
|
||||
|
||||
// Same mutation on both: remove every edge of one relation kind
|
||||
for (const e of edges) {
|
||||
if (e.relation === removeRel) {
|
||||
batched.removeRelation(e.srcKey, e.relation, e.dstKey);
|
||||
sequential.removeRelation(e.srcKey, e.relation, e.dstKey);
|
||||
}
|
||||
}
|
||||
|
||||
const b = allChecks(batched, users);
|
||||
const s = allChecks(sequential, users);
|
||||
for (const key of Object.keys(b)) {
|
||||
if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
|
||||
fail(`post-mutation parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
|
||||
}
|
||||
}
|
||||
return { removed: removeRel };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
users: rigor.gen.int(1, 4),
|
||||
mids: rigor.gen.int(0, 4),
|
||||
removeRel: rigor.gen.oneOf(['viewer', 'member_of'])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('batch-mutation-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'batch-mutation-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-mutation-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `BATCH+MUTATION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user