Files
core/tests/rigor/relation-manager.test.js
T
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

407 lines
17 KiB
JavaScript

/**
* rigor/relation-manager.test.js — js-rigor property tests for RelationManager.
*
* RelationManager owns relation lifecycle (addRelation, removeRelation) and
* exposes getDirectRelation. Properties verified using a real Arbiter (no
* mocking — the manager surface is small and Arbiter construction is cheap):
*
* - addRelation(src, rel, dst) is idempotent (returns relationIndex, but the
* second call routes to _modifyRelation and updates the existing entry)
* - getDirectRelation(src, rel, dst) returns null for unknown tuples
* - getDirectRelation returns the live relation object after addRelation
* - removeRelation clears all five indexes (GraphIndices + arbiter.relations)
* - Cross-check: arbiter.indices.getDirectRelation === relationManager.getDirectRelation
* - _relationKeys Set size matches arbiter.relations length
* - _relationKeyToIndex points at the correct index in arbiter.relations
* - getDirectRelation after removeRelation returns null
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
/**
* Build an Arbiter pre-populated with the given relations. Each entry is
* {src, rel, dst, possibility}.
*/
function makeArbiter(initialRelations = []) {
const arbiter = new Arbiter();
const keyIds = new Map();
let nextId = 0;
for (const r of initialRelations) {
const srcKey = `u${r.src}`;
const dstKey = `d${r.dst}`;
if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility ?? 1.0 });
}
return arbiter;
}
describe('RelationManager.addRelation/removeRelation (rigor)', () => {
it('addRelation then getDirectRelation returns the live relation object', async () => {
async function check(src, rel, dst, possibility) {
const arbiter = makeArbiter();
arbiter.addNode(`u${src}`, 'user');
arbiter.addNode(`d${dst}`, 'doc');
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility });
const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
if (!result) {
throw new Error(`expected getDirectRelation(${srcId},${rel},${dstId}) to return relation, got null`);
}
if (result.possibility !== possibility) {
throw new Error(`possibility=${result.possibility}, expected ${possibility}`);
}
if (result.rel !== rel) {
throw new Error(`rel=${result.rel}, expected ${rel}`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.int(0, 20),
rigor.gen.enum(RELATIONS),
rigor.gen.int(0, 20),
rigor.gen.float({ min: 0.01, max: 1 })
)
)],
rigor.crucible([
rigor.invariant('add-and-get', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 800 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-and-get');
assert.ok(inv);
assert.equal(inv.passed, true, `addRelation→getDirectRelation contract violated in ${inv.failureCount} cases`);
});
it('addRelation is idempotent: re-add with same (src,rel,dst) updates in place, not adds new', async () => {
async function check(src, rel, dst, possibility1, possibility2) {
const arbiter = makeArbiter();
arbiter.addNode(`u${src}`, 'user');
arbiter.addNode(`d${dst}`, 'doc');
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility1 });
const initialLen = arbiter.relations.length;
// Re-add with different possibility
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility2 });
// Length should not have grown (duplicate → modify, not insert)
if (arbiter.relations.length !== initialLen) {
throw new Error(`relations array grew from ${initialLen} to ${arbiter.relations.length} after duplicate add`);
}
// _relationKeys Set should have only one entry for this (src, rel, dst)
if (arbiter.relationManager._relationKeys.size !== 1) {
throw new Error(`_relationKeys.size=${arbiter.relationManager._relationKeys.size}, expected 1`);
}
// The new possibility should be reflected in getDirectRelation
const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
if (!result) {
throw new Error(`getDirectRelation returned null after duplicate add`);
}
if (result.possibility !== possibility2) {
throw new Error(`possibility=${result.possibility}, expected ${possibility2} (the second add)`);
}
return true;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.int(0, 20),
rigor.gen.enum(RELATIONS),
rigor.gen.int(0, 20),
rigor.gen.float({ min: 0.01, max: 1 }),
rigor.gen.float({ min: 0.01, max: 1 })
)
)],
rigor.crucible([
rigor.invariant('addRelation-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 800 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-idempotent');
assert.ok(inv);
assert.equal(inv.passed, true, `addRelation idempotence violated in ${inv.failureCount} cases`);
});
it('removeRelation clears all five indexes and returns getDirectRelation to null', async () => {
async function check(src, rel, dst) {
const arbiter = makeArbiter();
arbiter.addNode(`u${src}`, 'user');
arbiter.addNode(`d${dst}`, 'doc');
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: 0.5 });
// Verify pre-conditions
if (!arbiter.relationManager.getDirectRelation(srcId, rel, dstId)) {
throw new Error('pre-condition failed: relation not present after add');
}
arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);
// After remove, getDirectRelation should be null
const after = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
if (after !== null && after !== undefined) {
throw new Error(`expected null after remove, got ${JSON.stringify(after)}`);
}
// arbiter.indices should also be clear
const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
if (fromIdx !== undefined) {
throw new Error(`arbiter.indices still has entry after remove: ${JSON.stringify(fromIdx)}`);
}
// _relationKeys should not have this tuple
const key = arbiter.relationManager._makeRelationKey(srcId, rel, dstId);
if (arbiter.relationManager._relationKeys.has(key)) {
throw new Error(`_relationKeys still has ${key} after remove`);
}
// byName should not have this relation
const byName = arbiter.indices.getRelationsByName(rel);
if (byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsByName still contains removed relation`);
}
const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
if (bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsFromSrc still contains removed relation`);
}
const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
if (byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsToDst still contains removed relation`);
}
return true;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.int(0, 20),
rigor.gen.enum(RELATIONS),
rigor.gen.int(0, 20)
)
)],
rigor.crucible([
rigor.invariant('remove-clears-indexes', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 800 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-clears-indexes');
assert.ok(inv);
assert.equal(inv.passed, true, `removeRelation index cleanup violated in ${inv.failureCount} cases`);
});
it('index coherence: every relation in arbiter.relations is queryable through every index', async () => {
async function check(relations) {
// Cap input size to keep the test fast
if (relations.length > 5) return null;
const arbiter = makeArbiter();
const seenKeys = new Set();
for (const r of relations) {
const srcKey = `u${r.src}`;
const dstKey = `d${r.dst}`;
if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
const k = `${r.src}|${r.rel}|${r.dst}`;
if (seenKeys.has(k)) continue; // skip duplicates — relation manager would modify
seenKeys.add(k);
arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility });
}
// For each unique (src, rel, dst) in the input, verify all indexes agree
for (const k of seenKeys) {
const [src, rel, dst] = k.split('|');
const srcKey = `u${src}`;
const dstKey = `d${dst}`;
const srcId = arbiter.nodeIdByKey.get(srcKey);
const dstId = arbiter.nodeIdByKey.get(dstKey);
// 1. arbiter.indices.getDirectRelation
const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
if (!fromIdx) throw new Error(`arbiter.indices.getDirectRelation(${k}) returned null`);
// 2. relationManager.getDirectRelation (should agree)
const fromMgr = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
if (fromMgr !== fromIdx) {
throw new Error(`relationManager.getDirectRelation !== arbiter.indices.getDirectRelation for ${k}`);
}
// 3. byName must contain this relation
const byName = arbiter.indices.getRelationsByName(rel);
if (!byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsByName(${rel}) missing ${k}`);
}
// 4. bySrc must contain this relation
const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
if (!bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsFromSrc(${srcId},${rel}) missing ${k}`);
}
// 5. byDst must contain this relation
const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
if (!byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
throw new Error(`getRelationsToDst(${dstId},${rel}) missing ${k}`);
}
}
// Cross-consistency: _relationKeys.size matches arbiter.relations.length
// (modulo dedup — we passed unique keys above)
if (arbiter.relationManager._relationKeys.size !== arbiter.relations.length) {
throw new Error(
`_relationKeys.size=${arbiter.relationManager._relationKeys.size} !== ` +
`arbiter.relations.length=${arbiter.relations.length}`
);
}
return true;
}
const relGen = rigor.gen.array(
rigor.gen.tuple(
rigor.gen.int(0, 4),
rigor.gen.enum(RELATIONS),
rigor.gen.int(0, 4),
rigor.gen.float({ min: 0.01, max: 1 })
).map(([src, rel, dst, p]) => ({ src, rel, dst, possibility: p })),
1, 5
);
const report = await rigor.campaign(
[rigor.fn('check', check, rigor.args(relGen))],
rigor.crucible([
rigor.invariant('index-coherence', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 1500 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'index-coherence');
assert.ok(inv);
assert.equal(inv.passed, true, `index coherence violated in ${inv.failureCount} cases`);
});
it('add-then-remove sequence: state matches initial (no leaks)', async () => {
async function check(operations) {
const arbiter = makeArbiter();
// Each operation: [opCode, src, rel, dst]
// 0 = addRelation, 1 = removeRelation
for (const op of operations) {
if (op[0] === 0) {
const [, src, rel, dst] = op;
if (!arbiter.nodeIdByKey.has(`u${src}`)) arbiter.addNode(`u${src}`, 'user');
if (!arbiter.nodeIdByKey.has(`d${dst}`)) arbiter.addNode(`d${dst}`, 'doc');
arbiter.addRelation(`u${src}`, rel, `d${dst}`);
} else if (op[0] === 1) {
const [, src, rel, dst] = op;
if (!arbiter.nodeIdByKey.has(`u${src}`)) continue; // node not added yet
if (!arbiter.nodeIdByKey.has(`d${dst}`)) continue;
arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);
}
}
// Verify: for every key in _relationKeys, arbiter.relations has it
// AND arbiter.indices.getDirectRelation returns it
// _relationKeys keys are formatted as "srcId|relationId|dstId" where
// relationId is an internal id, not the relation name. Build a reverse map.
const relIdToName = new Map();
for (const [name, id] of arbiter.relationManager._relationNameToId) {
relIdToName.set(id, name);
}
for (const key of arbiter.relationManager._relationKeys) {
const [srcIdStr, relationIdStr, dstIdStr] = key.split('|');
const srcId = parseInt(srcIdStr, 10);
const dstId = parseInt(dstIdStr, 10);
const relId = parseInt(relationIdStr, 10);
const relName = relIdToName.get(relId);
if (!relName) {
throw new Error(`cannot resolve relationId ${relId} to name (keyManager state: ${JSON.stringify([...relIdToName])})`);
}
const idx = arbiter.indices.getDirectRelation(srcId, relName, dstId);
if (!idx) {
throw new Error(`_relationKeys has ${key} but indices.getDirectRelation returns null`);
}
// The relation should also be findable in arbiter.relations
const found = arbiter.relations.some(r => r.src === srcId && r.dst === dstId && r.rel === relName);
if (!found) {
throw new Error(`_relationKeys has ${key} but arbiter.relations does not`);
}
}
// Verify: for every relation in arbiter.relations, _relationKeys has it
for (const r of arbiter.relations) {
const key = arbiter.relationManager._makeRelationKey(r.src, r.rel, r.dst);
if (!arbiter.relationManager._relationKeys.has(key)) {
throw new Error(`arbiter.relations has ${r.src}|${r.rel}|${r.dst} but _relationKeys missing`);
}
}
return true;
}
const opGen = rigor.gen.array(
rigor.gen.oneOf([
rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4)),
rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4))
]),
1, 8
);
const report = await rigor.campaign(
[rigor.fn('check', check, rigor.args(opGen))],
rigor.crucible([
rigor.invariant('add-remove-roundtrip', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 1500 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-roundtrip');
assert.ok(inv);
assert.equal(inv.passed, true, `add-then-remove sequence violated in ${inv.failureCount} cases`);
});
it('getDirectRelation returns null for unknown (src, rel, dst)', async () => {
async function check(src, rel, dst) {
const arbiter = makeArbiter();
// No relations added; any query should return null/undefined
// Note: src/dst are arbitrary ints because we never added those nodes,
// so the relation can't exist by definition.
const result = arbiter.relationManager.getDirectRelation(src, rel, dst);
if (result !== null && result !== undefined) {
throw new Error(`expected null/undefined for unknown relation, got ${JSON.stringify(result)}`);
}
return true;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.int(0, 1000),
rigor.gen.enum(RELATIONS),
rigor.gen.int(0, 1000)
)
)],
rigor.crucible([
rigor.invariant('getDirectRelation-unknown', ({ error, errorMessage }) => !error && !errorMessage)
])
).run({ effort: 500 });
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-unknown');
assert.ok(inv);
assert.equal(inv.passed, true, `getDirectRelation-unknown contract violated in ${inv.failureCount} cases`);
});
});