Files
core/tests/property-based/authorization-properties.test.js
John Dvorak 717ae1031e 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.
2026-07-31 13:44:06 -07:00

330 lines
11 KiB
JavaScript

/**
* Graph Engine Property-Based Tests — Authorization & Rule Engine
*
* Tests complex algorithmic P1 items from the ASSESSMENT:
* A10 — Diamond graph intersections (visited backtracking)
* A20 — ChainRule dedup + path-count cap (DoS)
* A21 — minPossibility threshold propagation
* A23 — Reverse chain compilation
* A25 — Per-level short-circuit (never→stop)
*
* These tests MAY fail — that's expected. We're establishing the correct
* behavior baseline before fixing the complex algorithms.
*
* Run: node lib/tests/property-based/authorization-properties.test.js
*/
import { Arbiter } from '../../src/core/Arbiter.js';
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeArbiter() {
return new Arbiter({
fastConstructionMode: true,
enableInference: false,
disableCaching: true,
disableChainCaching: true,
disableDirectCaching: true,
});
}
function addNode(a, key, type = 'test') {
a.addNode(key, type);
}
function addRel(a, src, rel, dst, p = 1.0) {
try { a.addRelation(src, rel, dst, { possibility: p }); } catch { /* duplicate */ }
}
function doCheck(a, user, rel, obj) {
return a.check(user, rel, obj, {
partialGraph: null,
includeMeta: false,
explain: false,
fastPath: false,
});
}
// ---------------------------------------------------------------------------
// Arbitraries
// ---------------------------------------------------------------------------
const keyArb = fc.string({ minLength: 2, maxLength: 10 }).map(s => s.replace(/[^a-zA-Z0-9]/g, '_'));
const relArb = fc.constantFrom('owns', 'member', 'viewer', 'editor', 'parent_of');
// ---------------------------------------------------------------------------
// Test 1: Diamond Graph (A10) — two paths should not crash
// ---------------------------------------------------------------------------
function testDiamondGraph() {
console.log('\n=== A10: Diamond Graph Intersections ===');
const arbiter = makeArbiter();
const a = 'u:alice', b = 'u:bob', c = 'u:charlie', d = 'f:doc1';
[a, b, c, d].forEach(k => addNode(arbiter, k, k.startsWith('u:') ? 'user' : 'file'));
// Diamond: a → b → d, a → c → d
addRel(arbiter, a, 'member', b);
addRel(arbiter, b, 'member', d);
addRel(arbiter, a, 'member', c);
addRel(arbiter, c, 'member', d);
arbiter.setRelationConfig('member', { type: 'direct' });
// Two-hop chain over the diamond
arbiter.setRelationConfig('two_hop', {
type: 'chain',
chain: {
steps: [
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
],
},
});
console.log(' Direct check a→member→a (self):');
try {
const r1 = doCheck(arbiter, a, 'member', a);
console.log(' result:', r1?.possibility, r1?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' Two-hop a→two_hop→d (via diamond):');
try {
const r2 = doCheck(arbiter, a, 'two_hop', d);
console.log(' result:', r2?.possibility, r2?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' PASS: no crash on diamond graph');
}
// ---------------------------------------------------------------------------
// Test 2: minPossibility Threshold (A21)
// ---------------------------------------------------------------------------
function testMinPossibility() {
console.log('\n=== A21: minPossibility Threshold Propagation ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
// Direct relation
addRel(arbiter, 'u:alice', 'viewer', 'f:doc1', 0.8);
arbiter.setRelationConfig('viewer', {
type: 'direct',
minPossibility: 0.5,
});
console.log(' Check with possibility=0.8, threshold=0.5:');
const r1 = doCheck(arbiter, 'u:alice', 'viewer', 'f:doc1');
console.log(' result:', r1?.possibility, r1?.allowed);
// Now check with NO relation — should deny
console.log(' Check non-existent relation (should deny):');
const r2 = doCheck(arbiter, 'u:alice', 'viewer', 'f:doc2');
console.log(' result:', r2?.possibility, r2?.allowed);
console.log(' PASS: threshold check completed');
}
// ---------------------------------------------------------------------------
// Test 3: NEVER Short-Circuit (A25)
// ---------------------------------------------------------------------------
function testNeverShortCircuit() {
console.log('\n=== A25: NEVER Per-Level Short-Circuit ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
addRel(arbiter, 'u:alice', 'viewer', 'f:doc1');
arbiter.setRelationConfig('viewer', { type: 'direct' });
// Test a NEVER-gated rule — should short-circuit evaluation
arbiter.setRelationConfig('never_viewer', {
type: 'defeasible',
mode: 'normal',
never: [{ type: 'direct', relation: 'viewer' }],
strict: [{ type: 'direct', relation: 'viewer' }],
defeasible: [{ type: 'direct', relation: 'viewer' }],
});
console.log(' NEVER rule check (should short-circuit to deny):');
const r1 = doCheck(arbiter, 'u:alice', 'never_viewer', 'f:doc1');
console.log(' result:', r1?.possibility, r1?.allowed);
console.log(' PASS: NEVER short-circuit completed');
}
// ---------------------------------------------------------------------------
// Test 4: ChainRule Path-Count Cap (A20)
// ---------------------------------------------------------------------------
function testChainDedup() {
console.log('\n=== A20: ChainRule Dedup & Path-Count Cap ===');
const arbiter = makeArbiter();
// Build a dense barabasi-albert-like network
const nodes = [];
for (let i = 0; i < 20; i++) {
const key = `n:${i}`;
addNode(arbiter, key, 'test');
nodes.push(key);
}
// Connect many edges
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
if (Math.random() > 0.7) continue;
addRel(arbiter, nodes[i], 'member', nodes[j]);
addRel(arbiter, nodes[j], 'member', nodes[i]);
}
}
arbiter.setRelationConfig('member', { type: 'direct' });
arbiter.setRelationConfig('long_chain', {
type: 'chain',
chain: {
steps: [
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
{ relation: 'member', direction: 'out' },
],
},
});
console.log(' 3-hop chain on dense graph (20 nodes, many edges):');
try {
const r1 = doCheck(arbiter, nodes[0], 'long_chain', nodes[19]);
console.log(' result:', r1?.possibility, r1?.allowed);
} catch (e) {
console.log(' ERROR:', e.message);
}
console.log(' PASS: chain traversal on dense graph completed');
}
// ---------------------------------------------------------------------------
// Test 5: Reverse Chain Compilation (A23)
// ---------------------------------------------------------------------------
function testReverseChain() {
console.log('\n=== A23: Reverse Chain Compilation ===');
const arbiter = makeArbiter();
addNode(arbiter, 'u:alice', 'user');
addNode(arbiter, 'f:doc1', 'file');
addNode(arbiter, 'f:doc2', 'file');
addRel(arbiter, 'f:doc1', 'viewer', 'u:alice'); // doc1 viewer is alice
addRel(arbiter, 'f:doc1', 'parent', 'f:doc2'); // doc1 parent is doc2
arbiter.setRelationConfig('viewer', { type: 'direct' });
arbiter.setRelationConfig('parent', { type: 'direct' });
// Reverse chain: doc2 ←parent← doc1 ←viewer← u:alice
// Forward: alice → viewer → doc1 → parent → doc2
arbiter.setRelationConfig('inherited_viewer', {
type: 'chain',
chain: {
steps: [
{ relation: 'viewer', direction: 'out' },
{ relation: 'parent', direction: 'out' },
],
},
});
console.log(' Forward chain alice→viewer→doc1→parent→doc2:');
const r1 = doCheck(arbiter, 'u:alice', 'inherited_viewer', 'f:doc2');
console.log(' result:', r1?.possibility, r1?.allowed);
// Also try reverse: use direction 'in'
arbiter.setRelationConfig('reverse_inherited', {
type: 'chain',
reverse: true,
chain: {
steps: [
{ relation: 'viewer', direction: 'in' },
{ relation: 'parent', direction: 'in' },
],
},
});
console.log(' Reverse chain doc2→parent←doc1→viewer←alice:');
const r2 = doCheck(arbiter, 'f:doc2', 'reverse_inherited', 'u:alice');
console.log(' result:', r2?.possibility, r2?.allowed);
console.log(' PASS: reverse chain compilation completed');
}
// ---------------------------------------------------------------------------
// Test 6: Diamond Graph Intersection (A10) — visited backtracking
// ---------------------------------------------------------------------------
function testDiamondIntersection() {
console.log('\n=== A10: Diamond Graph Intersection (visited backtracking) ===');
const arbiter = makeArbiter();
const a = 'u:alice', b = 'u:bob', t = 'g:team', d1 = 'f:doc1', d2 = 'f:doc2';
[a, b, t, d1, d2].forEach(k => addNode(arbiter, k, k.startsWith('u:') ? 'user' : k.startsWith('g:') ? 'group' : 'file'));
// Path 1: alice --friend--> bob --member--> team --access--> doc2
addRel(arbiter, a, 'friend', b);
addRel(arbiter, b, 'member', t);
addRel(arbiter, t, 'access', d2);
// Path 2: alice --owner--> doc1 --parent--> doc2
addRel(arbiter, a, 'owner', d1);
addRel(arbiter, d1, 'parent', d2);
['friend', 'member', 'access', 'owner', 'parent'].forEach(r => {
arbiter.setRelationConfig(r, { type: 'direct' });
});
// Intersection: BOTH paths must succeed (exercises visited backtracking)
arbiter.setRelationConfig('both_paths', { type: 'logical', intersection: { rules: [
{ type: 'chain', chain: { steps: [
{ relation: 'friend', direction: 'out' }, { relation: 'member', direction: 'out' }, { relation: 'access', direction: 'out' }
]}},
{ type: 'chain', chain: { steps: [
{ relation: 'owner', direction: 'out' }, { relation: 'parent', direction: 'out' }
]}}
]}});
const r1 = doCheck(arbiter, a, 'both_paths', d2);
console.log(' Diamond intersection:', r1?.possibility, r1?.reason);
if (r1?.possibility > 0) {
console.log(' PASS: both paths correctly intersected');
} else if (r1?.reason === 'cycle') {
console.log(' FAIL: visited backtracking bug — second path saw cycle from first');
} else {
console.log(' UNEXPECTED:', r1?.reason);
}
}
// ---------------------------------------------------------------------------
// Run all
// ---------------------------------------------------------------------------
try { testDiamondGraph(); } catch (e) { console.error('DIAMOND FAIL:', e.message); }
try { testMinPossibility(); } catch (e) { console.error('THRESHOLD FAIL:', e.message); }
try { testNeverShortCircuit(); } catch (e) { console.error('NEVER FAIL:', e.message); }
try { testChainDedup(); } catch (e) { console.error('CHAIN FAIL:', e.message); }
try { testReverseChain(); } catch (e) { console.error('REVERSE FAIL:', e.message); }
try { testDiamondIntersection(); } catch (e) { console.error('DIAMOND INTERSECTION FAIL:', e.message); }
console.log('\n=== All tests executed ===');