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,179 @@
|
||||
/**
|
||||
* rigor/authorization-config-consistency.test.js — js-rigor property tests
|
||||
* for relation-config semantics and evaluation-path consistency.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - FAST/RULE PARITY: for a direct config, the fast path (AuthorizationChecker
|
||||
* direct lookup) and the rule-evaluation path agree on possibility.
|
||||
* - OVERRIDE: a direct config's `relation` override is honored by BOTH
|
||||
* paths — checking `can_delete` (which checks `mfa`) succeeds iff the
|
||||
* `mfa` edge exists, and fails iff it is absent.
|
||||
* - OVERRIDE PARITY: the override result equals a plain direct check on
|
||||
* the overridden relation itself.
|
||||
* - REMEDIATION: a missing injectable source surfaces unified remediation
|
||||
* naming the relation and object; a present witness produces none.
|
||||
* - OVERRIDE + REMEDIATION: with an injectable overridden relation, the
|
||||
* denied result carries a remediation option for that relation.
|
||||
*/
|
||||
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;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function buildArbiter({ override = false, injectable = false, relation = 'mfa' } = {}) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
if (injectable) {
|
||||
arbiter.setRelationConfig(relation, {
|
||||
type: 'source',
|
||||
relation,
|
||||
injectable: true,
|
||||
provides: 'Proof'
|
||||
});
|
||||
}
|
||||
const directConfig = { type: 'direct' };
|
||||
if (override) {
|
||||
directConfig.relation = relation;
|
||||
}
|
||||
arbiter.setRelationConfig('can_delete', directConfig);
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
describe('Authorization config consistency (rigor)', () => {
|
||||
it('FAST/RULE PARITY: plain direct config agrees across both evaluation paths', async () => {
|
||||
async function check(p) {
|
||||
const arbiter = buildArbiter();
|
||||
arbiter.addRelation('user:1', 'can_delete', 'doc:1', { possibility: p });
|
||||
|
||||
// Fast path: default check (direct config short-circuits)
|
||||
const fast = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
|
||||
// Rule path: force full rule evaluation by disabling the fast path
|
||||
const rule = arbiter.check('user:1', 'can_delete', 'doc:1', { fastPath: false });
|
||||
|
||||
if (Math.abs(fast.possibility - p) > EPS) {
|
||||
fail(`fast path: expected ${p}, got ${fast.possibility}`);
|
||||
}
|
||||
if (Math.abs(rule.possibility - p) > EPS) {
|
||||
fail(`rule path: expected ${p}, got ${rule.possibility}`);
|
||||
}
|
||||
return { fast, rule };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('path-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'authz-config-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'path-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `FAST/RULE parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('OVERRIDE: direct config relation override is honored; absent witness denies', async () => {
|
||||
async function check({ hasWitness, p }) {
|
||||
const arbiter = buildArbiter({ override: true, relation: 'mfa' });
|
||||
// The overridden relation itself must be evaluable for the parity check
|
||||
arbiter.setRelationConfig('mfa', { type: 'direct' });
|
||||
if (hasWitness) {
|
||||
arbiter.addRelation('user:1', 'mfa', 'doc:1', { possibility: p });
|
||||
}
|
||||
|
||||
const withWitness = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
if (hasWitness) {
|
||||
if (Math.abs(withWitness.possibility - p) > EPS) {
|
||||
fail(`override with witness: expected ${p}, got ${withWitness.possibility}`);
|
||||
}
|
||||
} else if (withWitness.possibility !== 0) {
|
||||
fail(`override without witness must deny, got ${withWitness.possibility}`);
|
||||
}
|
||||
|
||||
// Parity: can_delete (overriding to mfa) must equal checking mfa directly
|
||||
const direct = arbiter.check('user:1', 'mfa', 'doc:1');
|
||||
if (Math.abs(withWitness.possibility - direct.possibility) > EPS) {
|
||||
fail(`override parity: can_delete=${withWitness.possibility} vs mfa=${direct.possibility}`);
|
||||
}
|
||||
return { withWitness, direct };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
hasWitness: rigor.gen.boolean(),
|
||||
p: rigor.gen.oneOf([0.25, 0.5, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('override-honored', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'authz-config-override' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-honored');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `OVERRIDE contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('REMEDIATION: missing injectable witness surfaces unified remediation; present witness has none', async () => {
|
||||
async function check({ hasWitness }) {
|
||||
const arbiter = buildArbiter({ injectable: true, relation: 'mfa' });
|
||||
arbiter.setRelationConfig('can_delete', { type: 'direct', relation: 'mfa' });
|
||||
if (hasWitness) {
|
||||
arbiter.addRelation('user:1', 'mfa', 'doc:1', 1.0);
|
||||
}
|
||||
|
||||
const result = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
if (hasWitness) {
|
||||
if (result.possibility !== 1) {
|
||||
fail(`witness present should grant, got ${result.possibility}`);
|
||||
}
|
||||
if (result.remediation) {
|
||||
fail(`witness present must not produce remediation, got ${JSON.stringify(result.remediation)}`);
|
||||
}
|
||||
} else {
|
||||
if (result.possibility !== 0) {
|
||||
fail(`witness missing should deny, got ${result.possibility}`);
|
||||
}
|
||||
const options = result.remediation?.options;
|
||||
if (!Array.isArray(options) || options.length === 0) {
|
||||
fail(`missing witness must produce remediation options`);
|
||||
}
|
||||
const mfaOption = options.find(o => o.relation === 'mfa' && o.object === 'doc:1');
|
||||
if (!mfaOption) {
|
||||
fail(`remediation must name relation 'mfa' and object 'doc:1', got ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ hasWitness: rigor.gen.boolean() })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('remediation-contract', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'authz-config-remediation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remediation-contract');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `REMEDIATION contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* rigor/authorization-graph.test.js — js-rigor property tests for the
|
||||
* authorization graph semantics.
|
||||
*
|
||||
* Properties verified (the core authorization-graph contract):
|
||||
*
|
||||
* - DIRECT: an existing edge grants with EXACTLY its possibility;
|
||||
* a different relation on the same pair denies (0).
|
||||
* - BOUNDS: every check result possibility is ∈ [0, 1].
|
||||
* - ABSENT: no edges → 0 for any relation.
|
||||
* - CHAIN (weakest link): a chain's possibility equals the MIN of the
|
||||
* edge possibilities along the traversed path (transitivity holds).
|
||||
* - MULTI-PATH (disjunctive): with parallel paths the possibility is the
|
||||
* MAX over paths of the per-path minimum.
|
||||
* - TUPLE-TO-USERSET: group membership grants the group's owned objects
|
||||
* at the weakest-link possibility.
|
||||
* - MUTATION: removing an edge invalidates a previously-granting check
|
||||
* (no stale cache grant).
|
||||
*
|
||||
* Each property runs through js-rigor's generator + bandit pipeline, so
|
||||
* boundary values (possibility 0/1, self-loops, multi-hop chains) are
|
||||
* exercised automatically, with shrinking on failure.
|
||||
*/
|
||||
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;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe('Authorization graph semantics (rigor)', () => {
|
||||
it('DIRECT: existing edge grants with its exact possibility; other relations deny', async () => {
|
||||
async function check({ p, wrongRel }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_write', { type: 'direct' });
|
||||
arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });
|
||||
|
||||
const grant = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
||||
if (Math.abs(grant.possibility - p) > EPS) {
|
||||
fail(`direct grant: expected ${p}, got ${grant.possibility}`);
|
||||
}
|
||||
if (grant.possibility < 0 || grant.possibility > 1) {
|
||||
fail(`possibility out of bounds: ${grant.possibility}`);
|
||||
}
|
||||
const deny = arbiter.check('user:alice', 'can_write', 'doc:secret');
|
||||
if (deny.possibility !== 0) {
|
||||
fail(`different relation should deny, got ${deny.possibility}`);
|
||||
}
|
||||
return { grant, deny };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
||||
wrongRel: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('direct-exact', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'authz-graph-direct' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'direct-exact');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `DIRECT contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('BOUNDS + ABSENT: no edges → 0; every possibility ∈ [0,1]', async () => {
|
||||
async function check(nodes) {
|
||||
const arbiter = new Arbiter();
|
||||
const keys = [];
|
||||
for (let i = 0; i < nodes; i++) {
|
||||
keys.push(`node:${i}`);
|
||||
arbiter.addNode(`node:${i}`, 'entity');
|
||||
}
|
||||
arbiter.setRelationConfig('rel_x', { type: 'direct' });
|
||||
const src = keys[0];
|
||||
const dst = keys[keys.length - 1];
|
||||
const result = arbiter.check(src, 'rel_x', dst);
|
||||
if (result.possibility !== 0) {
|
||||
fail(`empty graph must deny, got ${result.possibility}`);
|
||||
}
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
fail(`possibility out of bounds: ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(rigor.gen.int(2, 6)))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('absent-denies', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'authz-graph-absent' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'absent-denies');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `ABSENT contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('CHAIN (weakest link): transitivity with min possibility along the path', async () => {
|
||||
async function check({ p1, p2 }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('group:eng', 'group');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'group_reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 });
|
||||
arbiter.addRelation('group:eng', 'group_reads', 'doc:secret', { possibility: p2 });
|
||||
|
||||
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
||||
const expected = Math.min(p1, p2);
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`chain: expected ${expected} (min(${p1},${p2})), got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p1: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1]),
|
||||
p2: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('weakest-link', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'authz-graph-chain' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'weakest-link');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `CHAIN contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('MULTI-PATH (disjunctive): max over paths of the per-path minimum', async () => {
|
||||
async function check({ p1a, p2a, p1b, p2b }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('mid:1', 'group');
|
||||
arbiter.addNode('mid:2', 'group');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'group_reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
// Path 1: alice → mid:1 → doc
|
||||
arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: p1a });
|
||||
arbiter.addRelation('mid:1', 'group_reads', 'doc:secret', { possibility: p2a });
|
||||
// Path 2: alice → mid:2 → doc
|
||||
arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: p1b });
|
||||
arbiter.addRelation('mid:2', 'group_reads', 'doc:secret', { possibility: p2b });
|
||||
|
||||
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
||||
const expected = Math.max(Math.min(p1a, p2a), Math.min(p1b, p2b));
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`multi-path: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p1a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
||||
p2a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
||||
p1b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
||||
p2b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('disjunctive-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'authz-graph-multipath' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'disjunctive-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `MULTI-PATH contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('TUPLE-TO-USERSET: group membership grants owned objects at weakest-link possibility', async () => {
|
||||
async function check({ pm, po }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('group:eng', 'group');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'owner',
|
||||
computedRelation: 'member_of',
|
||||
reverse: false
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
|
||||
// Tupleset edge: object → group via 'owner' (document owns the group),
|
||||
// matching the Zanzibar tupleset convention used by the engine.
|
||||
arbiter.addRelation('doc:secret', 'owner', 'group:eng', { possibility: po });
|
||||
|
||||
const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
|
||||
const expected = Math.min(pm, po);
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`tuple-to-userset: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
// A user outside the group must not gain access via the same object
|
||||
arbiter.addNode('user:eve', 'user');
|
||||
const denied = arbiter.check('user:eve', 'can_access', 'doc:secret');
|
||||
if (denied.possibility !== 0) {
|
||||
fail(`non-member must deny, got ${denied.possibility}`);
|
||||
}
|
||||
return { result, denied };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pm: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
|
||||
po: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('tus-weakest-link', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'authz-graph-tus' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-weakest-link');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `TUPLE-TO-USERSET contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('MUTATION: removing an edge revokes a previously-granting check (no stale cache)', async () => {
|
||||
async function check({ p }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });
|
||||
|
||||
// Warm the caches with a granting check
|
||||
const before = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
||||
if (before.possibility <= 0) {
|
||||
fail(`setup: expected grant, got ${before.possibility}`);
|
||||
}
|
||||
|
||||
// Mutate the graph: remove the edge, then re-check
|
||||
arbiter.removeRelation('user:alice', 'can_read', 'doc:secret');
|
||||
const after = arbiter.check('user:alice', 'can_read', 'doc:secret');
|
||||
if (after.possibility !== 0) {
|
||||
fail(`revoked access still granted: ${after.possibility}`);
|
||||
}
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ p: rigor.gen.oneOf([0.25, 0.5, 0.75, 1]) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('revoke-invalidates', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'authz-graph-mutation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'revoke-invalidates');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `MUTATION contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* rigor/binary-mode-parity.test.js — js-rigor property tests for binary
|
||||
* (threshold) evaluation mode.
|
||||
*
|
||||
* Binary mode is a separate evaluation path (_checkBinary + binary rules
|
||||
* short-circuit) with dual thresholds: allow when strength >=
|
||||
* minAllowPossibility, deny when deny-strength >= maxDenyPossibility.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - BINARY-NORMAL AGREEMENT: for every config kind and threshold,
|
||||
* binary.allow === (normal-mode possibility >= minAllowPossibility)
|
||||
* and the reason string is consistent.
|
||||
* - CONTINUOUS POSSIBILITY: binary.possibility reports the real
|
||||
* continuous strength (=== normal-mode possibility), never a binarized
|
||||
* 0/1 — for direct, chain, and logical operator configs.
|
||||
* - EXCLUSION DUAL THRESHOLD: top-level exclusion sets deny exactly when
|
||||
* the negated child's strength >= maxDenyPossibility.
|
||||
* - FASTPATH THRESHOLD PARITY: fastPath:true + minPossibility evaluates
|
||||
* the same continuous possibility as normal mode.
|
||||
* - MUTATION FRESHNESS: after every mutation, binary and normal checks
|
||||
* agree, and binary reflects the new state even with caching enabled.
|
||||
*/
|
||||
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];
|
||||
const THRESHOLDS = [0.2, 0.5, 0.8];
|
||||
const DENY_THRESHOLDS = [0.3, 0.6, 0.9];
|
||||
|
||||
const KINDS = 10; // + defeasible (when+unless), (when+never), (always+when+unless)
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
arb.addNode('user:alice', 'user');
|
||||
arb.addNode('group:eng', 'group');
|
||||
arb.addNode('doc:1', 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('r3', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
arb.setRelationConfig('viewer', { type: 'direct' });
|
||||
arb.setRelationConfig('strict', { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
function childRule(rel) {
|
||||
return { type: 'direct', relation: rel };
|
||||
}
|
||||
|
||||
function makeConfig(kind) {
|
||||
switch (kind) {
|
||||
case 0: return childRule('r1');
|
||||
case 1: return {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
};
|
||||
case 2: return { union: [childRule('r1'), childRule('r2')] };
|
||||
case 3: return { intersection: [childRule('r1'), childRule('r2')] };
|
||||
case 4: return { exclusion: [childRule('r1'), childRule('r2')] };
|
||||
case 5: return { union: [childRule('r1'), { exclusion: [childRule('r2'), childRule('r3')] }] };
|
||||
case 6: return { union: [makeConfig(1), childRule('r1')] };
|
||||
case 7: return { type: 'defeasible', when: childRule('r1'), unless: childRule('r2') };
|
||||
case 8: return { type: 'defeasible', when: childRule('r1'), never: childRule('r2') };
|
||||
case 9: return { type: 'defeasible', always: childRule('r3'), when: childRule('r1'), unless: childRule('r2') };
|
||||
default: throw new Error(`bad kind ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function edgeMap(edges) {
|
||||
const m = new Map();
|
||||
for (const [rel, p] of edges) m.set(rel, p);
|
||||
return m;
|
||||
}
|
||||
|
||||
function oraclePossibility(kind, em) {
|
||||
const p = (rel) => em.has(rel) ? em.get(rel) : 0;
|
||||
switch (kind) {
|
||||
case 0: return p('r1');
|
||||
case 1: {
|
||||
// min over path edges; 0 if either edge missing
|
||||
if (!em.has('member_of') || !em.has('viewer')) return 0;
|
||||
return Math.min(em.get('member_of'), em.get('viewer'));
|
||||
}
|
||||
case 2: return Math.max(p('r1'), p('r2'));
|
||||
case 3: return Math.min(p('r1'), p('r2'));
|
||||
case 4: return p('r1') * (1 - p('r2'));
|
||||
case 5: return Math.max(p('r1'), p('r2') * (1 - p('r3')));
|
||||
case 6: {
|
||||
const chain = (!em.has('member_of') || !em.has('viewer')) ? 0 : Math.min(em.get('member_of'), em.get('viewer'));
|
||||
return Math.max(chain, p('r1'));
|
||||
}
|
||||
case 7: return p('r1') * (1 - p('r2'));
|
||||
case 8: return p('r2') >= 0.5 ? 0 : p('r1');
|
||||
case 9: return Math.max(p('r3'), p('r1')) * (1 - p('r2'));
|
||||
default: throw new Error(`bad kind ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function oracleDeniedPossibility(kind, em) {
|
||||
// The negated child strength for top-level exclusion (kind 4 only)
|
||||
if (kind !== 4) return 0;
|
||||
return em.has('r2') ? em.get('r2') : 0;
|
||||
}
|
||||
|
||||
function randomEdges(seedState) {
|
||||
// Deterministic pseudo-random via mulberry32
|
||||
const edges = [];
|
||||
const rels = ['r1', 'r2', 'r3', 'member_of', 'viewer', 'banned', 'strict'];
|
||||
for (const rel of rels) {
|
||||
if (seedState.next() < 0.55) {
|
||||
edges.push([rel, POS[Math.floor(seedState.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges, mode) {
|
||||
for (const [rel, p] of edges) {
|
||||
const dst = rel === 'member_of' ? 'group:eng' : 'doc:1';
|
||||
if (mode === 'add') {
|
||||
const src = rel === 'viewer' ? 'group:eng' : 'user:alice';
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
} else {
|
||||
const src = rel === 'viewer' ? 'group:eng' : 'user:alice';
|
||||
arb.removeRelation(src, rel, dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyParity(arb, kind, { minAllow, maxDeny, edges }) {
|
||||
const config = arb.relationConfigs.get('target');
|
||||
const em = edgeMap(edges);
|
||||
const expectedP = oraclePossibility(kind, em);
|
||||
const expectedDenied = oracleDeniedPossibility(kind, em);
|
||||
|
||||
const normal = arb.check('user:alice', 'target', 'doc:1');
|
||||
const binary = arb.check('user:alice', 'target', 'doc:1', { binary: true, minAllowPossibility: minAllow, maxDenyPossibility: maxDeny });
|
||||
|
||||
// BINARY-NORMAL AGREEMENT (decision-level, always exact)
|
||||
const expectedAllow = expectedP >= minAllow;
|
||||
const expectedDeny = expectedDenied >= maxDeny;
|
||||
const expectedReason = expectedAllow ? 'allow' : (expectedDeny && kind === 4) ? 'deny' : 'insufficient_confidence';
|
||||
|
||||
if (binary.allow !== expectedAllow) {
|
||||
fail(`allow mismatch kind=${kind} p=${expectedP} t=${minAllow}: expected allow=${expectedAllow}, got ${binary.allow} (normal=${normal.possibility})`);
|
||||
}
|
||||
if (binary.deny !== expectedDeny) {
|
||||
fail(`deny mismatch kind=${kind} denied=${expectedDenied} denyT=${maxDeny}: expected deny=${expectedDeny}, got ${binary.deny}`);
|
||||
}
|
||||
if (binary.reason !== expectedReason) {
|
||||
fail(`reason mismatch kind=${kind}: expected ${expectedReason}, got ${binary.reason}`);
|
||||
}
|
||||
|
||||
// VALUE contract: binary mode evaluates via the rule path (chain children
|
||||
// collapse sub-threshold paths to 0) and must match exactly whenever no
|
||||
// operator early exit can have fired. fastPath normal mode evaluates via
|
||||
// the compiled path, which reports true continuous values (no collapse);
|
||||
// its early-exit gates are the same.
|
||||
const expectedValue = expectedBinaryValue(kind, em, minAllow);
|
||||
if (!earlyExitMayFire(kind, em, minAllow)) {
|
||||
if (Math.abs(binary.possibility - expectedValue) > EPS) {
|
||||
fail(`binary value mismatch kind=${kind}: expected ${expectedValue}, got ${binary.possibility} (normal=${normal.possibility}, p=${expectedP}, t=${minAllow})`);
|
||||
}
|
||||
const fp = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: minAllow });
|
||||
if (Math.abs(fp.possibility - expectedP) > EPS) {
|
||||
fail(`fastPath value mismatch kind=${kind}: expected ${expectedP}, got ${fp.possibility} (t=${minAllow})`);
|
||||
}
|
||||
} else {
|
||||
// Early exit may have fired: values are approximations, decisions exact.
|
||||
const fp = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: minAllow });
|
||||
if ((fp.possibility >= minAllow) !== (expectedP >= minAllow)) {
|
||||
fail(`fastPath decision mismatch kind=${kind}: p=${expectedP} t=${minAllow} fp=${fp.possibility}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NORMAL mode always reports the true continuous possibility.
|
||||
if (Math.abs(normal.possibility - expectedP) > EPS) {
|
||||
fail(`normal possibility mismatch kind=${kind}: expected ${expectedP}, got ${normal.possibility}`);
|
||||
}
|
||||
|
||||
return { expectedP, binary, normal };
|
||||
}
|
||||
|
||||
function chainPossibilityOf(em) {
|
||||
if (!em.has('member_of') || !em.has('viewer')) return 0;
|
||||
return Math.min(em.get('member_of'), em.get('viewer'));
|
||||
}
|
||||
|
||||
function childPossibilitiesOf(kind, em) {
|
||||
const p = (rel) => em.has(rel) ? em.get(rel) : 0;
|
||||
switch (kind) {
|
||||
case 2: return [p('r1'), p('r2')];
|
||||
case 3: return [p('r1'), p('r2')];
|
||||
case 5: return [p('r1'), p('r2') * (1 - p('r3'))];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold-mode value oracle. Binary (and fastPath) evaluation runs in
|
||||
* threshold mode: chain children collapse to 0 when their possibility is
|
||||
* below the allow threshold (path pruning — decision-sound), while direct
|
||||
* children keep continuous values. Union/intersection/exclusion aggregate
|
||||
* the collapsed child values.
|
||||
*/
|
||||
function expectedBinaryValue(kind, em, t) {
|
||||
const p = (rel) => em.has(rel) ? em.get(rel) : 0;
|
||||
const chainV = (kind === 1 || kind === 6) ? chainPossibilityOf(em) : 0;
|
||||
const chainCollapsed = chainV >= t ? chainV : 0;
|
||||
switch (kind) {
|
||||
case 0: return p('r1');
|
||||
case 1: return chainCollapsed;
|
||||
case 2: return Math.max(p('r1'), p('r2'));
|
||||
case 3: return Math.min(p('r1'), p('r2'));
|
||||
case 4: return p('r1') * (1 - p('r2'));
|
||||
case 5: return Math.max(p('r1'), p('r2') * (1 - p('r3')));
|
||||
case 6: return Math.max(chainCollapsed, p('r1'));
|
||||
case 7: return p('r1') * (1 - p('r2'));
|
||||
case 8: return p('r2') >= 0.5 ? 0 : p('r1');
|
||||
case 9: return Math.max(p('r3'), p('r1')) * (1 - p('r2'));
|
||||
default: throw new Error(`bad kind ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True: an early-exit may have fired during evaluation, making the reported
|
||||
* value a first-crossing approximation (union: first child >= t; chain child
|
||||
* is first in kind 6). Decisions remain exact regardless.
|
||||
*/
|
||||
function earlyExitMayFire(kind, em, t) {
|
||||
const p = (rel) => em.has(rel) ? em.get(rel) : 0;
|
||||
switch (kind) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 4: return false;
|
||||
case 2: return childPossibilitiesOf(kind, em).some(v => v >= t);
|
||||
case 3: return childPossibilitiesOf(kind, em).some(v => v < t);
|
||||
case 5: return childPossibilitiesOf(kind, em).some(v => v >= t);
|
||||
case 6: return chainPossibilityOf(em) >= t;
|
||||
case 7:
|
||||
case 8:
|
||||
case 9: return false;
|
||||
default: throw new Error(`bad kind ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Binary (threshold) mode parity (rigor)', () => {
|
||||
it('BINARY-NORMAL AGREEMENT across all config kinds and thresholds', async () => {
|
||||
async function check({ kind, seed, minAllow, maxDeny }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
arb.setRelationConfig('target', makeConfig(kind));
|
||||
applyEdges(arb, edges, 'add');
|
||||
return verifyParity(arb, kind, { minAllow, maxDeny, edges });
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
kind: rigor.gen.int(0, KINDS - 1),
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
minAllow: rigor.gen.oneOf(THRESHOLDS),
|
||||
maxDeny: rigor.gen.oneOf(DENY_THRESHOLDS)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('binary-normal-agreement', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'binary-mode-config-matrix' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'binary-normal-agreement');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `binary parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('MUTATION FRESHNESS: binary and normal agree after every mutation with caching enabled', async () => {
|
||||
async function check({ kind, seed, minAllow, maxDeny, mutations }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
arb.setRelationConfig('target', makeConfig(kind));
|
||||
applyEdges(arb, edges, 'add');
|
||||
|
||||
// Warm the cache with an initial binary check
|
||||
arb.check('user:alice', 'target', 'doc:1', { binary: true, minAllowPossibility: minAllow, maxDenyPossibility: maxDeny });
|
||||
|
||||
for (let i = 0; i < mutations; i++) {
|
||||
// Mutate: toggle a random edge's presence
|
||||
const rels = ['r1', 'r2', 'r3', 'member_of', 'viewer', 'banned', 'strict'];
|
||||
const rel = rels[Math.floor(rng.next() * rels.length)];
|
||||
const dst = rel === 'member_of' ? 'group:eng' : 'doc:1';
|
||||
const src = rel === 'viewer' ? 'group:eng' : 'user:alice';
|
||||
const existing = arb.indices.getDirectRelation(
|
||||
arb.resolveNodeId(src), rel, arb.resolveNodeId(dst)
|
||||
);
|
||||
const idx = edges.findIndex(e => e[0] === rel);
|
||||
if (existing) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
if (idx !== -1) edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
if (idx !== -1) edges[idx][1] = p;
|
||||
else edges.push([rel, p]);
|
||||
}
|
||||
|
||||
verifyParity(arb, kind, { minAllow, maxDeny, edges });
|
||||
}
|
||||
return { mutations };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
kind: rigor.gen.int(0, KINDS - 1),
|
||||
seed: rigor.gen.int(1, 50000),
|
||||
minAllow: rigor.gen.oneOf(THRESHOLDS),
|
||||
maxDeny: rigor.gen.oneOf(DENY_THRESHOLDS),
|
||||
mutations: rigor.gen.int(2, 6)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('mutation-freshness', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'binary-mode-mutation-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'mutation-freshness');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `mutation freshness violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* rigor/cache-parity.test.js — js-rigor property tests for cache
|
||||
* correctness under mutation.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - CACHE ON/OFF PARITY: two identical arbiters — one with caching
|
||||
* enabled, one with `disableCaching: true` — driven through IDENTICAL
|
||||
* random mutation sequences (adds/removes across direct, override and
|
||||
* chain configs). After EVERY mutation, every check must agree
|
||||
* EXACTLY. Any divergence means a stale direct-check, rule-result or
|
||||
* chain cache survived a mutation.
|
||||
* - TTL CONTRACT: with an injected fake clock, cached entries expire at
|
||||
* the configured TTL — an entry read after its TTL is reported
|
||||
* expired, never hit.
|
||||
* - OVERRIDE + CACHE: relation-override configs (can_read → viewer)
|
||||
* participate in invalidation — mutations on the base relation flip
|
||||
* cached override checks immediately (regression for the stale-grant
|
||||
* bug found by the model-based campaign).
|
||||
*/
|
||||
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 buildArbiter({ caching }) {
|
||||
const arbiter = new Arbiter({
|
||||
disableCaching: !caching,
|
||||
disableChainCaching: !caching,
|
||||
disableDirectCaching: !caching
|
||||
});
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('group:eng', 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('member_of', { 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;
|
||||
}
|
||||
|
||||
const RELS = ['viewer', 'member_of'];
|
||||
const RELATIONS = [
|
||||
['add', 'user:alice', 'viewer', 'doc:1'],
|
||||
['add', 'user:alice', 'member_of', 'group:eng'],
|
||||
['add', 'group:eng', 'viewer', 'doc:1'],
|
||||
['remove', 'user:alice', 'viewer', 'doc:1'],
|
||||
['remove', 'user:alice', 'member_of', 'group:eng'],
|
||||
['remove', 'group:eng', 'viewer', 'doc:1']
|
||||
];
|
||||
|
||||
function applyOp(arbiter, op, p) {
|
||||
const [kind, src, rel, dst] = op;
|
||||
if (kind === 'add') {
|
||||
arbiter.addRelation(src, rel, dst, { possibility: p });
|
||||
} else {
|
||||
arbiter.removeRelation(src, rel, dst);
|
||||
}
|
||||
}
|
||||
|
||||
function allChecks(arbiter) {
|
||||
const results = {};
|
||||
for (const rel of ['can_read', 'can_access']) {
|
||||
results[rel] = arbiter.check('user:alice', rel, 'doc:1').possibility;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('Cache correctness under mutation (rigor)', () => {
|
||||
it('CACHE ON/OFF PARITY: cached and uncached arbiters never diverge through mutation sequences', async () => {
|
||||
async function check(ops) {
|
||||
const cached = buildArbiter({ caching: true });
|
||||
const uncached = buildArbiter({ caching: false });
|
||||
|
||||
for (const [kind, src, rel, dst, p] of ops) {
|
||||
applyOp(cached, [kind, src, rel, dst], p);
|
||||
applyOp(uncached, [kind, src, rel, dst], p);
|
||||
|
||||
const c = allChecks(cached);
|
||||
const u = allChecks(uncached);
|
||||
for (const rel of Object.keys(c)) {
|
||||
if (Math.abs(c[rel] - u[rel]) > EPS) {
|
||||
fail(`diverged on ${rel} after ${kind}(${src},${rel},${dst},${p}): cached=${c[rel]}, uncached=${u[rel]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ops: ops.length };
|
||||
}
|
||||
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.tuple(
|
||||
rigor.gen.oneOf([0, 1, 2, 3, 4, 5]), // index into RELATIONS
|
||||
rigor.gen.oneOf(POS)
|
||||
),
|
||||
1, 12
|
||||
).map((pairs) => pairs.map(([idx, p]) => [...RELATIONS[idx], p]));
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(opGen))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('cache-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'cache-onoff-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cache-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `CACHE PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('OVERRIDE + CACHE: base-relation mutations immediately flip cached override checks', async () => {
|
||||
async function check({ p1, p2 }) {
|
||||
const arbiter = buildArbiter({ caching: true });
|
||||
|
||||
// Warm the override-path cache with a grant
|
||||
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p1 });
|
||||
const granted = arbiter.check('user:alice', 'can_read', 'doc:1');
|
||||
if (Math.abs(granted.possibility - p1) > EPS) {
|
||||
fail(`setup: expected ${p1}, got ${granted.possibility}`);
|
||||
}
|
||||
|
||||
// Mutate the BASE relation — the cached override check must flip NOW
|
||||
arbiter.removeRelation('user:alice', 'viewer', 'doc:1');
|
||||
const revoked = arbiter.check('user:alice', 'can_read', 'doc:1');
|
||||
if (revoked.possibility !== 0) {
|
||||
fail(`override grant survived base removal: ${revoked.possibility}`);
|
||||
}
|
||||
|
||||
// Re-add with a different possibility — must flip again immediately
|
||||
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p2 });
|
||||
const regranted = arbiter.check('user:alice', 'can_read', 'doc:1');
|
||||
if (Math.abs(regranted.possibility - p2) > EPS) {
|
||||
fail(`override grant did not update to ${p2}: ${regranted.possibility}`);
|
||||
}
|
||||
return { granted, revoked, regranted };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p1: rigor.gen.oneOf(POS),
|
||||
p2: rigor.gen.oneOf(POS)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('override-cache-fresh', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'cache-override-freshness' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-cache-fresh');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `OVERRIDE CACHE violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('TTL CONTRACT: injected clock reports entries expired after the TTL window', async () => {
|
||||
async function check({ ttl, delay }) {
|
||||
const arbiter = new Arbiter({ directCheckCacheTTL: ttl });
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
arbiter.addRelation('user:alice', 'can_read', 'doc:1', { possibility: 1 });
|
||||
|
||||
let now = 1000;
|
||||
arbiter.decisionCache.clock = () => now;
|
||||
|
||||
arbiter.check('user:alice', 'can_read', 'doc:1');
|
||||
now += delay;
|
||||
|
||||
const cache = arbiter.decisionCache;
|
||||
const key = arbiter.authChecker._getDirectCheckCacheKey('user:alice', 'can_read', 'doc:1');
|
||||
const [result, status] = cache.peekDirect(key);
|
||||
const expectedStatus = delay >= ttl ? 'expired' : 'hit';
|
||||
if (status !== expectedStatus) {
|
||||
fail(`ttl=${ttl}, delay=${delay}: expected '${expectedStatus}', got '${status}'`);
|
||||
}
|
||||
if (expectedStatus === 'hit' && result?.possibility !== 1) {
|
||||
fail(`hit entry lost its result: ${JSON.stringify(result)}`);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
ttl: rigor.gen.oneOf([100, 500, 1000]),
|
||||
delay: rigor.gen.oneOf([0, 50, 100, 400, 600, 1500])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('ttl-contract', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'cache-ttl-contract' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttl-contract');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `TTL CONTRACT violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* rigor/chain-rule.test.js — js-rigor property tests for ChainRule.
|
||||
*
|
||||
* ChainRule follows a chain of relations and collects values along the path.
|
||||
* Properties verified:
|
||||
*
|
||||
* - Empty steps → possibility=0, reason='no_chain_steps_defined'
|
||||
* - Empty graph (no relations) → possibility=0
|
||||
* - 1-step chain with matching relation → possibility > 0
|
||||
* - 2-step chain through intermediate node → possibility > 0
|
||||
* - result.possibility ∈ [0, 1]
|
||||
* - bypassPLTC: true skips reachability check
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ChainRule } from '../../src/authorization/rules/ChainRule.js';
|
||||
|
||||
describe('ChainRule evaluation (rigor)', () => {
|
||||
it('empty steps → possibility=0, reason=no_chain_steps_defined', async () => {
|
||||
async function check() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0 for empty steps, got ${result.possibility}`);
|
||||
}
|
||||
if (result.reason !== 'no_chain_steps_defined') {
|
||||
throw new Error(`expected reason='no_chain_steps_defined', got '${result.reason}'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('empty-steps', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'empty-steps');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `empty-steps contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] with various chain configurations', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('no matching path in graph → possibility=0', async () => {
|
||||
async function check() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
// No relations at all
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0 with no relations, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-path', ({ 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 === 'no-path');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-path contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('1-step chain with matching relation → possibility > 0', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
if (result.possibility <= 0) {
|
||||
throw new Error(`expected possibility>0 with path, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0.01, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('one-step-pos', ({ 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 === 'one-step-pos');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `one-step-pos contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('2-step chain through intermediate → possibility > 0 (when both legs exist)', async () => {
|
||||
async function check(strength1, strength2) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('team:eng', 'team');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'member_of', 'team:eng', { possibility: strength1 });
|
||||
arbiter.addRelation('team:eng', 'has_access', 'doc:secret', { possibility: strength2 });
|
||||
const rule = new ChainRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'chain', steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'has_access', direction: 'out' }
|
||||
] },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true, bypassPLTC: true }
|
||||
);
|
||||
// Path exists, so possibility should be > 0
|
||||
if (result.possibility <= 0) {
|
||||
throw new Error(`expected possibility>0 with 2-step path, got ${result.possibility}`);
|
||||
}
|
||||
// And it should be bounded by min(strength1, strength2) along the chain
|
||||
if (result.possibility > Math.min(strength1, strength2) + 0.01) {
|
||||
throw new Error(`possibility=${result.possibility} exceeds chain min(${strength1}, ${strength2})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('two-step-chain', ({ 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 === 'two-step-chain');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `two-step-chain contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* rigor/challenge-proof.test.js — js-rigor property tests for
|
||||
* PartialGraphContext.getChallengeProof.
|
||||
*
|
||||
* rigor.fn receives args positionally, not as a single destructured object.
|
||||
* getChallengeProof is a deterministic pure function on a Map of challenge
|
||||
* records; perfect target for property-based testing.
|
||||
* Properties verified:
|
||||
* - skipped: proof with expiresAt <= now is never returned
|
||||
* - skipped: proof with (now - issuedAt) > withinMs is never returned
|
||||
* - pick: returned proof has the largest issuedAt among passes
|
||||
* - null: when no proof passes filters, returns null
|
||||
* - passthrough: when withinMs is null/undefined, time filter is off
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
|
||||
|
||||
const CHALLENGE_NAMES = ['mfa', 'captcha', 'webauthn', 'password'];
|
||||
const SUBJECTS = ['user:abc', 'user:def', 'user:ghi', 'user:jkl'];
|
||||
|
||||
/**
|
||||
* Brute-force oracle: re-implements getChallengeProof in 6 lines.
|
||||
* Used as the oracle in the campaign — if it ever disagrees with the
|
||||
* production code, that's a bug.
|
||||
*/
|
||||
function bruteForceOracle(proofs, name, subjectId, withinMs, now) {
|
||||
const matching = proofs.filter(p =>
|
||||
p.name === name && p.subject === subjectId
|
||||
);
|
||||
let best = null;
|
||||
for (const proof of matching) {
|
||||
if (proof.expiresAt != null && proof.expiresAt <= now) continue;
|
||||
if (withinMs != null && now - proof.issuedAt > withinMs) continue;
|
||||
if (!best || proof.issuedAt > best.issuedAt) best = proof;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh PartialGraphContext for the given subject + proofs.
|
||||
* PartialGraphContext._resolveNodeId reads `arbiter.nodeIdByKey` to
|
||||
* share IDs with the parent graph. Pass a stub arbiter so the lookup
|
||||
* doesn't crash when the campaign shrinks inputs down to edge cases.
|
||||
*/
|
||||
function buildContext({ subjectIds, proofs }) {
|
||||
const stubArbiter = { nodeIdByKey: new Map() };
|
||||
const context = new PartialGraphContext(stubArbiter, { skipContext: false });
|
||||
for (const id of subjectIds) {
|
||||
context._resolveNodeId(id);
|
||||
}
|
||||
for (const proof of proofs) {
|
||||
context._addChallengeProof(proof);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
describe('PartialGraphContext.getChallengeProof (rigor)', () => {
|
||||
it('reference oracle matches production across fuzzed inputs', async () => {
|
||||
// Args are positional: (proofs, name, subject, withinMs, now).
|
||||
async function referenceCheck(proofs, name, subject, withinMs, now) {
|
||||
const ctx = buildContext({ subjectIds: [subject], proofs });
|
||||
const subjectId = ctx._resolveNodeId(subject);
|
||||
const actual = ctx.getChallengeProof(name, subjectId, withinMs, now);
|
||||
const expected = bruteForceOracle(proofs, name, subject, withinMs, now);
|
||||
if (expected === null) {
|
||||
if (actual !== null) {
|
||||
throw new Error(
|
||||
`expected null but got proof with issuedAt=${actual.issuedAt}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (actual === null) {
|
||||
throw new Error(
|
||||
`expected proof with issuedAt=${expected.issuedAt} but got null`
|
||||
);
|
||||
}
|
||||
if (actual.issuedAt !== expected.issuedAt) {
|
||||
throw new Error(
|
||||
`wrong proof returned: expected issuedAt=${expected.issuedAt}, got ${actual.issuedAt}`
|
||||
);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', referenceCheck,
|
||||
rigor.args(
|
||||
rigor.gen.array(
|
||||
rigor.gen.object({
|
||||
name: rigor.gen.enum(CHALLENGE_NAMES),
|
||||
subject: rigor.gen.enum(SUBJECTS),
|
||||
issuedAt: rigor.gen.int(0, 100000),
|
||||
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
|
||||
}),
|
||||
0, 5
|
||||
),
|
||||
rigor.gen.enum(CHALLENGE_NAMES),
|
||||
rigor.gen.enum(SUBJECTS),
|
||||
rigor.gen.option(rigor.gen.int(0, 100000)),
|
||||
rigor.gen.int(0, 200000)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant(
|
||||
'oracle-matches-brute-force',
|
||||
({ error, errorMessage }) => !error && !errorMessage
|
||||
)
|
||||
])
|
||||
).run({ effort: 1500 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
}
|
||||
const oracle = report.crucibleVerdict?.invariants?.find(
|
||||
i => i.name === 'oracle-matches-brute-force'
|
||||
);
|
||||
assert.ok(oracle, 'oracle invariant reported');
|
||||
assert.equal(oracle.passed, true,
|
||||
`getChallengeProof disagrees with brute-force oracle: ${oracle.failureCount} failures`);
|
||||
});
|
||||
|
||||
it('never returns an expired proof (expiresAt <= now)', async () => {
|
||||
async function expiredCheck(proof, now) {
|
||||
const ctx = buildContext({ subjectIds: [proof.subject], proofs: [proof] });
|
||||
const subjectId = ctx._resolveNodeId(proof.subject);
|
||||
const result = ctx.getChallengeProof(proof.name, subjectId, null, now);
|
||||
if (result && result.expiresAt != null && result.expiresAt <= now) {
|
||||
throw new Error(`returned expired proof: expiresAt=${result.expiresAt}, now=${now}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('expired', expiredCheck,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
name: rigor.gen.enum(CHALLENGE_NAMES),
|
||||
subject: rigor.gen.enum(SUBJECTS),
|
||||
issuedAt: rigor.gen.int(0, 50000),
|
||||
expiresAt: rigor.gen.int(0, 100000)
|
||||
}),
|
||||
rigor.gen.int(50001, 200000) // now is always after the issuedAt range
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-expired', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
}
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-expired');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`getChallengeProof returned an expired proof in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('returns the most-recently-issued proof when withinMs=null', async () => {
|
||||
async function recentCheck(proofs, name, subject, now) {
|
||||
const ctx = buildContext({ subjectIds: [subject], proofs });
|
||||
const subjectId = ctx._resolveNodeId(subject);
|
||||
const result = ctx.getChallengeProof(name, subjectId, null, now);
|
||||
const nonExpired = proofs.filter(p =>
|
||||
p.name === name && p.subject === subject &&
|
||||
(p.expiresAt == null || p.expiresAt > now)
|
||||
);
|
||||
if (nonExpired.length === 0) {
|
||||
if (result !== null) throw new Error('expected null, got result');
|
||||
return null;
|
||||
}
|
||||
let maxIssued = nonExpired[0].issuedAt;
|
||||
for (const p of nonExpired) {
|
||||
if (p.issuedAt > maxIssued) maxIssued = p.issuedAt;
|
||||
}
|
||||
if (!result || result.issuedAt !== maxIssued) {
|
||||
throw new Error(
|
||||
`expected issuedAt=${maxIssued}, got ${result ? result.issuedAt : 'null'}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('recent', recentCheck,
|
||||
rigor.args(
|
||||
rigor.gen.array(
|
||||
rigor.gen.object({
|
||||
name: rigor.gen.constant('mfa'),
|
||||
subject: rigor.gen.constant('user:abc'),
|
||||
issuedAt: rigor.gen.int(0, 100000),
|
||||
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
|
||||
}),
|
||||
1, 5
|
||||
),
|
||||
rigor.gen.constant('mfa'),
|
||||
rigor.gen.constant('user:abc'),
|
||||
rigor.gen.int(0, 200000)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('most-recent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
}
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'most-recent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`getChallengeProof did not return the most-recent proof in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('enforces withinMs freshness window', async () => {
|
||||
async function withinCheck(proof, withinMs, now) {
|
||||
const ctx = buildContext({
|
||||
subjectIds: [proof.subject],
|
||||
proofs: [proof]
|
||||
});
|
||||
const subjectId = ctx._resolveNodeId(proof.subject);
|
||||
const result = ctx.getChallengeProof(proof.name, subjectId, withinMs, now);
|
||||
const age = now - proof.issuedAt;
|
||||
const expired = proof.expiresAt != null && proof.expiresAt <= now;
|
||||
if (age > withinMs || expired) {
|
||||
if (result !== null) {
|
||||
throw new Error(
|
||||
`returned proof past withinMs window: age=${age}, withinMs=${withinMs}, expired=${expired}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`expected non-null result, got null. age=${age}, withinMs=${withinMs}, expired=${expired}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('within', withinCheck,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
name: rigor.gen.constant('mfa'),
|
||||
subject: rigor.gen.constant('user:abc'),
|
||||
issuedAt: rigor.gen.int(0, 50000),
|
||||
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
|
||||
}),
|
||||
rigor.gen.int(1, 100000),
|
||||
rigor.gen.int(0, 100000)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-window', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('TAP:', report.toTAP());
|
||||
}
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'within-window');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`withinMs window not enforced: ${inv.failureCount} failures`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* rigor/challenge-rule.test.js — js-rigor property tests for ChallengeRule.evaluate.
|
||||
*
|
||||
* ChallengeRule.resolveSubjectKey and ChallengeRule.resolveWithinMs are
|
||||
* pure functions on rule config. Properties:
|
||||
* - subjectKey explicit override beats subject type
|
||||
* - subject=user → userKey
|
||||
* - subject=object → objectKey
|
||||
* - subject=session → sessionKey (else userKey)
|
||||
* - withinMs/withinSeconds/withinMinutes/withinHours are equivalent (each unit * factor)
|
||||
* - at most one of the four `within` keys is used (others ignored)
|
||||
* - if none of the four is set, withinMs is null
|
||||
*
|
||||
* The proof lookup (ChallengeRule.evaluate path) is tested separately in
|
||||
* challenge-proof.test.js; here we focus on the resolver surface.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { ChallengeRule } from '../../src/authorization/rules/ChallengeRule.js';
|
||||
|
||||
const SUBJECT_TYPES = ['user', 'object', 'session', null, 'unknown'];
|
||||
|
||||
/**
|
||||
* Stub arbiter that satisfies BaseRule + ChallengeRule's surface needs.
|
||||
*/
|
||||
function makeStubArbiter() {
|
||||
return {
|
||||
nodeIdByKey: new Map(),
|
||||
keyByNodeId: new Map(),
|
||||
relations: [],
|
||||
nodes: new Map(),
|
||||
resolveNodeId(key /* , options */) { return 1; }
|
||||
};
|
||||
}
|
||||
|
||||
function makeRule() {
|
||||
return new ChallengeRule(makeStubArbiter());
|
||||
}
|
||||
|
||||
describe('ChallengeRule._resolveSubjectKey (rigor)', () => {
|
||||
it('explicit rule.subjectKey wins over rule.subject', async () => {
|
||||
async function check(subjectKey, subjectType, userKey, objectKey, sessionKey) {
|
||||
const rule = makeRule();
|
||||
const result = rule._resolveSubjectKey(
|
||||
{ subjectKey, subject: subjectType },
|
||||
userKey, objectKey, { sessionKey }
|
||||
);
|
||||
if (result !== subjectKey) {
|
||||
throw new Error(
|
||||
`expected ${subjectKey}, got ${result}. subject=${subjectType}, userKey=${userKey}, objectKey=${objectKey}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.enum(SUBJECT_TYPES),
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.string(1, 20)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('subjectKey-wins', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'subjectKey-wins');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`subjectKey override did not win in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('subject=user → userKey; subject=object → objectKey; subject=session → sessionKey or userKey', async () => {
|
||||
async function check(subjectType, userKey, objectKey, sessionKey, hasSession) {
|
||||
const rule = makeRule();
|
||||
const result = rule._resolveSubjectKey(
|
||||
{ subject: subjectType },
|
||||
userKey, objectKey,
|
||||
hasSession ? { sessionKey } : {}
|
||||
);
|
||||
let expected;
|
||||
switch (subjectType) {
|
||||
case 'object': expected = objectKey; break;
|
||||
case 'session': expected = sessionKey || userKey; break;
|
||||
case 'user':
|
||||
case null:
|
||||
case 'unknown':
|
||||
default: expected = userKey; break;
|
||||
}
|
||||
if (result !== expected) {
|
||||
throw new Error(
|
||||
`subject=${subjectType}: expected ${expected}, got ${result}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.enum(SUBJECT_TYPES),
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.string(1, 20),
|
||||
rigor.gen.boolean()
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('subject-mapping', ({ 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 === 'subject-mapping');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`subject type mapping incorrect in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChallengeRule._resolveWithinMs (rigor)', () => {
|
||||
/**
|
||||
* Custom generator: pick one of 5 candidate shapes and produce the
|
||||
* corresponding rule. Avoids the `undefined` field trap that
|
||||
* rigor.gen.object doesn't support.
|
||||
*/
|
||||
const withinRuleGen = rigor.gen.oneOf([
|
||||
// Only withinMs set
|
||||
rigor.gen.object({
|
||||
withinMs: rigor.gen.int(1, 10000),
|
||||
comparator: rigor.gen.constant(null)
|
||||
}),
|
||||
// Only withinSeconds set (no withinMs)
|
||||
rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(null),
|
||||
withinSeconds: rigor.gen.int(1, 100)
|
||||
}),
|
||||
// Only withinMinutes set
|
||||
rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(null),
|
||||
withinSeconds: rigor.gen.constant(null),
|
||||
withinMinutes: rigor.gen.int(1, 10)
|
||||
}),
|
||||
// Only withinHours set
|
||||
rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(null),
|
||||
withinSeconds: rigor.gen.constant(null),
|
||||
withinMinutes: rigor.gen.constant(null),
|
||||
withinHours: rigor.gen.int(1, 5)
|
||||
}),
|
||||
// Empty (no within key)
|
||||
rigor.gen.object({
|
||||
comparator: rigor.gen.string()
|
||||
})
|
||||
]);
|
||||
|
||||
it('withinMs/withinSeconds/withinMinutes/withinHours are equivalent', async () => {
|
||||
async function check(rule) {
|
||||
const r = makeRule();
|
||||
const result = r._resolveWithinMs(rule);
|
||||
// The rule produced by withinRuleGen may have a `null` value for
|
||||
// some within* keys. _resolveWithinMs treats both undefined AND
|
||||
// null as "absent" (its `!== undefined && !== null` check). So
|
||||
// for our generator, `null` and missing both count as absent.
|
||||
let expected = null;
|
||||
if (rule.withinMs !== undefined && rule.withinMs !== null) {
|
||||
expected = rule.withinMs;
|
||||
} else if (rule.withinSeconds !== undefined && rule.withinSeconds !== null) {
|
||||
expected = rule.withinSeconds * 1000;
|
||||
} else if (rule.withinMinutes !== undefined && rule.withinMinutes !== null) {
|
||||
expected = rule.withinMinutes * 60 * 1000;
|
||||
} else if (rule.withinHours !== undefined && rule.withinHours !== null) {
|
||||
expected = rule.withinHours * 60 * 60 * 1000;
|
||||
}
|
||||
if (result !== expected) {
|
||||
throw new Error(
|
||||
`rule=${JSON.stringify(rule)}: expected ${expected}, got ${result}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(withinRuleGen)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-units', ({ 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 === 'within-units');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`withinMs/withinSeconds/withinMinutes/withinHours conversion wrong in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('priority order: withinMs > withinSeconds > withinMinutes > withinHours', async () => {
|
||||
async function check(rule) {
|
||||
const r = makeRule();
|
||||
const result = r._resolveWithinMs(rule);
|
||||
let expected = null;
|
||||
if (rule.withinMs != null) expected = rule.withinMs;
|
||||
else if (rule.withinSeconds != null) expected = rule.withinSeconds * 1000;
|
||||
else if (rule.withinMinutes != null) expected = rule.withinMinutes * 60 * 1000;
|
||||
else if (rule.withinHours != null) expected = rule.withinHours * 60 * 60 * 1000;
|
||||
if (result !== expected) {
|
||||
throw new Error(`expected ${expected}, got ${result} for ${JSON.stringify(rule)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate rules where all four keys are populated. The priority
|
||||
// chain must pick withinMs.
|
||||
const allFourSet = rigor.gen.object({
|
||||
withinMs: rigor.gen.int(100, 500),
|
||||
withinSeconds: rigor.gen.int(1, 100),
|
||||
withinMinutes: rigor.gen.int(1, 10),
|
||||
withinHours: rigor.gen.int(1, 5)
|
||||
});
|
||||
// withinMs=0 — must still win (it's "set", even if value is 0)
|
||||
const msZero = rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(0),
|
||||
withinSeconds: rigor.gen.int(1, 100),
|
||||
withinMinutes: rigor.gen.int(1, 10),
|
||||
withinHours: rigor.gen.int(1, 5)
|
||||
});
|
||||
// withinMs absent, withinSeconds present
|
||||
const noMs = rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(null),
|
||||
withinSeconds: rigor.gen.int(1, 100),
|
||||
withinMinutes: rigor.gen.int(1, 10),
|
||||
withinHours: rigor.gen.int(1, 5)
|
||||
});
|
||||
// only withinMinutes present
|
||||
const onlyMin = rigor.gen.object({
|
||||
withinMs: rigor.gen.constant(null),
|
||||
withinSeconds: rigor.gen.constant(null),
|
||||
withinMinutes: rigor.gen.int(1, 10),
|
||||
withinHours: rigor.gen.int(1, 5)
|
||||
});
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.oneOf([allFourSet, msZero, noMs, onlyMin]))
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('within-priority', ({ 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 === 'within-priority');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`within key priority wrong in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('returns null when no within key is set', async () => {
|
||||
async function check(rule) {
|
||||
const r = makeRule();
|
||||
const result = r._resolveWithinMs(rule);
|
||||
if (result !== null) {
|
||||
throw new Error(`expected null, got ${result} for ${JSON.stringify(rule)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
other: rigor.gen.int(),
|
||||
comparator: rigor.gen.string()
|
||||
})
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('null-when-absent', ({ 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 === 'null-when-absent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`_resolveWithinMs returned non-null in ${inv.failureCount} cases when no key was set`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChallengeRule._buildRequirement (rigor)', () => {
|
||||
it('preserves challenge, subject, withinMs, status fields', async () => {
|
||||
async function check(challenge, subject, withinMs, status) {
|
||||
const r = makeRule();
|
||||
const result = r._buildRequirement(challenge, subject, withinMs, status);
|
||||
const expected = {
|
||||
name: challenge,
|
||||
subject,
|
||||
withinMs: withinMs || null,
|
||||
status
|
||||
};
|
||||
assert.deepStrictEqual(result, expected,
|
||||
`mismatch: result=${JSON.stringify(result)} expected=${JSON.stringify(expected)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.option(rigor.gen.int(0, 100000)),
|
||||
rigor.gen.enum(['missing', 'missing_context', 'expired'])
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('buildRequirement', ({ 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 === 'buildRequirement');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`_buildRequirement contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* rigor/check-explain-agreement.test.js — js-rigor property tests for the
|
||||
* agreement between arbiter.check() and arbiter.explain().
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - AGREEMENT: check().possibility equals explain().decision.possibility
|
||||
* on the same graph (the explain path must never diverge from the
|
||||
* check path).
|
||||
* - GRANT ⟺ USED FACTS: a grant (possibility > 0) is always accompanied
|
||||
* by provenance used_facts; a deny with no path produces none.
|
||||
* - INJECTABLE REMEDIATION: when the checked relation depends on an
|
||||
* injectable source that is absent, both check() and explain() surface
|
||||
* unified remediation naming the missing relation.
|
||||
* - CONFIG OVERRIDE: these invariants hold with relation-override configs
|
||||
* (can_read → viewer), where the provenance must report the effective
|
||||
* relation.
|
||||
*/
|
||||
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 buildArbiter(seedCase) {
|
||||
const { hasRelation, p, injectable, override } = seedCase;
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
|
||||
if (injectable) {
|
||||
arbiter.setRelationConfig('mfa', {
|
||||
type: 'source',
|
||||
relation: 'mfa',
|
||||
injectable: true,
|
||||
provides: 'Proof'
|
||||
});
|
||||
}
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_read', override
|
||||
? { type: 'direct', relation: 'viewer' }
|
||||
: { type: 'direct' });
|
||||
|
||||
if (hasRelation) {
|
||||
arbiter.addRelation('user:1', override ? 'viewer' : 'can_read', 'doc:1', { possibility: p });
|
||||
}
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
describe('check/explain agreement (rigor)', () => {
|
||||
it('AGREEMENT: check and explain always decide the same possibility', async () => {
|
||||
async function check(seedCase) {
|
||||
const arbiter = buildArbiter(seedCase);
|
||||
const checked = arbiter.check('user:1', 'can_read', 'doc:1');
|
||||
const explained = arbiter.explain('user:1', 'can_read', 'doc:1');
|
||||
|
||||
if (Math.abs(checked.possibility - explained.decision.possibility) > EPS) {
|
||||
fail(`agreement: check=${checked.possibility} vs explain=${explained.decision.possibility}`);
|
||||
}
|
||||
return explained.decision;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
hasRelation: rigor.gen.boolean(),
|
||||
p: rigor.gen.oneOf(POS),
|
||||
injectable: rigor.gen.boolean(),
|
||||
override: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('check-explain-agree', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'explain-agreement' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'check-explain-agree');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `AGREEMENT violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('GRANT ⟺ USED FACTS: provenance marks used facts exactly when granting', async () => {
|
||||
async function check(seedCase) {
|
||||
const arbiter = buildArbiter(seedCase);
|
||||
const checked = arbiter.check('user:1', 'can_read', 'doc:1');
|
||||
|
||||
if (seedCase.withPartial) {
|
||||
// Partial-graph mode: the fact arrives via the request's partial
|
||||
// graph, and provenance must mark it used iff the check grants.
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: seedCase.override ? 'viewer' : 'can_read',
|
||||
dst: 'doc:1',
|
||||
possibility: 0.9
|
||||
}
|
||||
]
|
||||
};
|
||||
const checked = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
const explained = arbiter.explain('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
const used = explained.audit?.provenance?.used_facts || [];
|
||||
// A grant must always be explainable by a used fact. (A deny may
|
||||
// also have used facts — a 0-possibility persistent fact that beat
|
||||
// a partial fact by trust precedence.)
|
||||
if (checked.possibility > 0 && !used.some(u => u.used === true)) {
|
||||
fail(`partial grant (${checked.possibility}) without a used fact`);
|
||||
}
|
||||
// Every used fact must correspond to a real edge in the effective source
|
||||
for (const u of used) {
|
||||
if (u.used === true && u.source !== 'persistent' && u.source !== 'partial') {
|
||||
fail(`used fact with unknown source: ${u.source}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plain-graph mode: provenance is not emitted; the trace must
|
||||
// contain a true decision node exactly when granting.
|
||||
const explained = arbiter.explain('user:1', 'can_read', 'doc:1');
|
||||
const traceTrue = (explained.trace?.path || []).filter(n => n.result === true).length;
|
||||
if (checked.possibility > 0 && traceTrue === 0) {
|
||||
fail(`grant (${checked.possibility}) without a true trace node`);
|
||||
}
|
||||
if (checked.possibility === 0 && traceTrue > 0) {
|
||||
fail(`deny with spurious true trace nodes`);
|
||||
}
|
||||
}
|
||||
return checked.possibility;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
hasRelation: rigor.gen.boolean(),
|
||||
p: rigor.gen.oneOf(POS),
|
||||
injectable: rigor.gen.boolean(),
|
||||
override: rigor.gen.boolean(),
|
||||
withPartial: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('used-facts-consistent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'explain-used-facts' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'used-facts-consistent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `USED FACTS violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('INJECTABLE REMEDIATION: missing witness surfaces remediation in both check and explain', async () => {
|
||||
async function check(seedCase) {
|
||||
const arbiter = buildArbiter({ ...seedCase, injectable: true, override: true });
|
||||
// can_delete depends on the injectable mfa source
|
||||
arbiter.setRelationConfig('can_delete', { type: 'direct', relation: 'mfa' });
|
||||
if (seedCase.hasRelation) {
|
||||
arbiter.addRelation('user:1', 'mfa', 'doc:1', { possibility: seedCase.p });
|
||||
}
|
||||
|
||||
const checked = arbiter.check('user:1', 'can_delete', 'doc:1');
|
||||
if (seedCase.hasRelation) {
|
||||
// Edge present: grants at its possibility when > 0; a 0-possibility
|
||||
// edge is present-but-no-confidence — deny, no remediation.
|
||||
if (checked.possibility !== seedCase.p) {
|
||||
fail(`witness present: expected ${seedCase.p}, got ${checked.possibility}`);
|
||||
}
|
||||
if (checked.remediation) {
|
||||
fail(`witness present must not carry remediation`);
|
||||
}
|
||||
} else {
|
||||
const options = checked.remediation?.options || [];
|
||||
const mfaOption = options.find(o => o.relation === 'mfa' && o.object === 'doc:1');
|
||||
if (!mfaOption) {
|
||||
fail(`missing witness must remediate mfa on doc:1, got ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
hasRelation: rigor.gen.boolean(),
|
||||
p: rigor.gen.oneOf([0, 0.25, 0.5, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('remediation-consistent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'explain-remediation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remediation-consistent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `REMEDIATION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* rigor/comparator-full-path.test.js — js-rigor property tests for
|
||||
* RelationalComparatorRule through the FULL check() pipeline (compiled
|
||||
* evaluator, rule collector, checker wiring) — the existing
|
||||
* relational-comparator-rule.test.js only exercises the rule directly.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - COMPARISON PARITY: value comparisons through check() agree with a
|
||||
* direct oracle (left > right with epsilon => high possibility +
|
||||
* values_compared_comparison_true; otherwise 0 + _comparison_false).
|
||||
* - VALUE FLOW: edge values reach the comparator from direct relations
|
||||
* on both the user and object perspectives (evaluateFrom auto/user/object).
|
||||
* - MUTATION FRESHNESS: value updates flip comparisons immediately
|
||||
* (with warm caches).
|
||||
* - PATH PARITY: compiled and rule-based paths agree exactly.
|
||||
* - BINARY DECISION: binary allow iff normal possibility >= threshold.
|
||||
*/
|
||||
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 VALUES = [0, 10, 50, 100, 1000];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
arb.addNode('user:alice', 'user');
|
||||
arb.addNode('doc:secret', 'doc');
|
||||
arb.setRelationConfig('has_balance', { type: 'direct' });
|
||||
arb.setRelationConfig('has_price', { type: 'direct' });
|
||||
arb.setRelationConfig('premium', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
});
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('Relational comparator full-path parity (rigor)', () => {
|
||||
it('COMPARISON + MUTATION PARITY through check()', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
|
||||
let balance = VALUES[Math.floor(rng.next() * VALUES.length)];
|
||||
let price = VALUES[Math.floor(rng.next() * VALUES.length)];
|
||||
arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 });
|
||||
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
||||
|
||||
const verify = (tag) => {
|
||||
const res = arb.check('user:alice', 'premium', 'doc:secret', {});
|
||||
const expected = balance > price ? 1 : 0;
|
||||
if (Math.abs(res.possibility - expected) > EPS) {
|
||||
fail(`${tag}: balance=${balance} price=${price} expected=${expected} got=${res.possibility} reason=${res.reason}`);
|
||||
}
|
||||
// Reason contract: the false outcome surfaces the comparator reason;
|
||||
// the true outcome carries it inside meta.allow (outer reason is
|
||||
// the generic allow_rule_matched).
|
||||
if (balance > price) {
|
||||
const metaRes = arb.check('user:alice', 'premium', 'doc:secret', { includeMeta: true });
|
||||
if (metaRes.meta?.allow?.reason !== 'values_compared_comparison_true') {
|
||||
fail(`${tag}: expected meta.allow.reason=values_compared_comparison_true, got ${metaRes.meta?.allow?.reason}`);
|
||||
}
|
||||
} else if (res.reason !== 'values_compared_comparison_false') {
|
||||
fail(`${tag}: expected reason=values_compared_comparison_false, got ${res.reason}`);
|
||||
}
|
||||
// Rule-path parity
|
||||
const rulePath = arb.check('user:alice', 'premium', 'doc:secret', { useCompiled: false });
|
||||
if (Math.abs(rulePath.possibility - expected) > EPS) {
|
||||
fail(`${tag}: rule path ${rulePath.possibility} vs expected ${expected}`);
|
||||
}
|
||||
// Binary decision parity
|
||||
const bin = arb.check('user:alice', 'premium', 'doc:secret', { binary: true, minAllowPossibility: 0.5 });
|
||||
if (bin.allow !== (expected >= 0.5)) {
|
||||
fail(`${tag}: binary allow=${bin.allow} expected=${expected >= 0.5}`);
|
||||
}
|
||||
};
|
||||
|
||||
verify('initial');
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (rng.next() < 0.5) {
|
||||
balance = VALUES[Math.floor(rng.next() * VALUES.length)];
|
||||
arb.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0 });
|
||||
} else {
|
||||
price = VALUES[Math.floor(rng.next() * VALUES.length)];
|
||||
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
||||
}
|
||||
verify(`mutation ${i}`);
|
||||
}
|
||||
return { balance, price };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('comparator-full-path', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'comparator-full-path-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-full-path');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `comparator full-path parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('AGGREGATION: multiple value-carrying edges aggregate by max for the operand', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
arb.addNode('mid:1', 'mid');
|
||||
|
||||
// Two balance edges (user -> mid1 -> doc via r1), values 100 and 40
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.addRelation('user:alice', 'r1', 'mid:1', { value: 100, possibility: 1.0 });
|
||||
arb.addRelation('mid:1', 'r1', 'doc:secret', { value: 40, possibility: 1.0 });
|
||||
const price = 50;
|
||||
arb.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0 });
|
||||
|
||||
// Operand over a chain: values collected along the chain aggregate
|
||||
arb.setRelationConfig('balance_chain', {
|
||||
type: 'chain',
|
||||
steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }]
|
||||
});
|
||||
arb.setRelationConfig('premium_chain', {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r1', direction: 'out' }] }, extractValue: true },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
});
|
||||
|
||||
const res = arb.check('user:alice', 'premium_chain', 'doc:secret', { includeMeta: true });
|
||||
// Values along the chain: 100 and 40; max aggregator -> 100 > 50 -> true
|
||||
if (res.meta?.allow?.reason !== 'values_compared_comparison_true' || Math.abs(res.possibility - 1) > EPS) {
|
||||
fail(`chain operand comparison: got reason=${res.reason} p=${res.possibility} allowReason=${res.meta?.allow?.reason}`);
|
||||
}
|
||||
return { res: res.possibility };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('comparator-aggregation', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'comparator-aggregation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'comparator-aggregation');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `comparator aggregation violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* rigor/compiled-rule-parity.test.js — js-rigor property tests for the
|
||||
* compiled evaluator vs the rule-based evaluator.
|
||||
*
|
||||
* Every config kind has TWO full evaluation implementations: the compiled
|
||||
* evaluator (default, via config._compiled) and the rule-based path
|
||||
* (useCompiled: false — LogicalOperators + rule handlers). They must
|
||||
* agree exactly on the same graph, through mutations, in both plain and
|
||||
* fastPath modes.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - CONFIG MATRIX PARITY: direct, chain (out/in), TTU, union,
|
||||
* intersection, exclusion, nested logical, and all three defeasible
|
||||
* shapes agree between compiled and rule paths on random graphs.
|
||||
* - MUTATION PARITY: after every random add/remove, both paths agree.
|
||||
* - FASTPATH PARITY: with fastPath + minAllowPossibility, both paths
|
||||
* report the same decision parity.
|
||||
*/
|
||||
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];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
const KINDS = 10;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const childRule = rel => ({ type: 'direct', relation: rel });
|
||||
|
||||
function makeConfig(kind) {
|
||||
switch (kind) {
|
||||
case 0: return childRule('r1');
|
||||
case 1: return { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] };
|
||||
case 2: return { type: 'chain', steps: [{ relation: 'r1', direction: 'in' }, { relation: 'r2', direction: 'in' }] };
|
||||
case 3: return { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' };
|
||||
case 4: return { union: [childRule('r1'), childRule('r2')] };
|
||||
case 5: return { intersection: [childRule('r1'), childRule('r2')] };
|
||||
case 6: return { exclusion: [childRule('r1'), childRule('r2')] };
|
||||
case 7: return { union: [childRule('r1'), { exclusion: [childRule('r2'), childRule('r1')] }] };
|
||||
case 8: return { type: 'defeasible', when: childRule('r1'), unless: childRule('r2') };
|
||||
case 9: return { type: 'defeasible', always: childRule('r2'), when: childRule('r1') };
|
||||
default: throw new Error(`bad kind ${kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function randomEdges(rng) {
|
||||
const edges = [];
|
||||
for (const rel of ['r1', 'r2']) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < 0.5) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function buildArbiter(kind) {
|
||||
const arb = new Arbiter();
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('owner', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
arb.setRelationConfig('target', makeConfig(kind));
|
||||
if (kind === 3) arb.addNode('group:eng', 'group');
|
||||
return arb;
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges, kind) {
|
||||
for (const [src, rel, dst, p] of edges) {
|
||||
if (rel === 'r1' || rel === 'r2') arb.addRelation(src, rel, dst, { possibility: p });
|
||||
}
|
||||
if (kind === 3) {
|
||||
// TTU: random tuple + membership edges
|
||||
const rng = mulberry32(42);
|
||||
if (rng.next() < 0.7) arb.addRelation('doc:1', 'owner', 'group:eng', { possibility: POS[Math.floor(rng.next() * POS.length)] });
|
||||
if (rng.next() < 0.7) arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: POS[Math.floor(rng.next() * POS.length)] });
|
||||
}
|
||||
}
|
||||
|
||||
describe('Compiled vs rule-path parity (rigor)', () => {
|
||||
it('CONFIG MATRIX + MUTATION PARITY: both evaluators agree on every config kind', async () => {
|
||||
async function check({ seed, kind, mutations }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter(kind);
|
||||
applyEdges(arb, edges, kind);
|
||||
|
||||
const verify = (tag) => {
|
||||
const compiled = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
const rulePath = arb.check('user:alice', 'target', 'doc:1', { useCompiled: false });
|
||||
if (Math.abs(compiled.possibility - rulePath.possibility) > EPS) {
|
||||
fail(`${tag} kind=${kind}: compiled=${compiled.possibility} rule=${rulePath.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
if (compiled.reason !== rulePath.reason && !(compiled.reason === undefined && rulePath.reason === undefined)) {
|
||||
// reasons may be phrased differently across paths; only possibility must agree
|
||||
}
|
||||
// fastPath parity: decisions must agree
|
||||
const fpC = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: 0.5 });
|
||||
const fpR = arb.check('user:alice', 'target', 'doc:1', { fastPath: true, minAllowPossibility: 0.5, useCompiled: false });
|
||||
if ((fpC.possibility >= 0.5) !== (fpR.possibility >= 0.5)) {
|
||||
fail(`${tag} kind=${kind}: fastPath decision divergence compiled=${fpC.possibility} rule=${fpR.possibility}`);
|
||||
}
|
||||
};
|
||||
|
||||
verify('initial');
|
||||
|
||||
const rels = ['r1', 'r2'];
|
||||
for (let i = 0; i < mutations; i++) {
|
||||
const rel = rels[Math.floor(rng.next() * 2)];
|
||||
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
||||
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
verify(`mutation ${i}`);
|
||||
}
|
||||
return { kind };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
kind: rigor.gen.int(0, KINDS - 1),
|
||||
mutations: rigor.gen.int(1, 5)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('compiled-rule-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 2000, seed: 'compiled-rule-config-matrix' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'compiled-rule-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `compiled/rule parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* rigor/computed-rule.test.js — js-rigor property tests for ComputedRule.
|
||||
*
|
||||
* ComputedRule delegates to arbiter.authChecker.check(userKey, computedRelation,
|
||||
* objectKey, options) and adapts the result. Properties verified:
|
||||
*
|
||||
* - result.possibility equals the delegated authChecker.check result.possibility
|
||||
* - result.reason defaults to 'computed_delegation' if delegated has no reason
|
||||
* - result.reason passes through the delegated reason when present
|
||||
* - meta.ruleType='computed' and meta.computedRelation=rule.relation
|
||||
* - meta.delegated=true
|
||||
* - collectedValues pass through from delegated result
|
||||
* - trackEvaluation=true produces a populated result.evaluation block
|
||||
* - result.shape is stable across many inputs
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { ComputedRule } from '../../src/authorization/rules/ComputedRule.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||||
|
||||
/**
|
||||
* Build an arbiter stub whose authChecker.check returns a programmable value.
|
||||
* Records all calls for assertions.
|
||||
*/
|
||||
function makeArbiter(delegate) {
|
||||
const calls = [];
|
||||
const arbiter = {
|
||||
authChecker: {
|
||||
check(userKey, computedRelation, objectKey, options) {
|
||||
calls.push({ userKey, computedRelation, objectKey, hasVisited: !!options._visited, hasCurrentRel: !!options._currentRelation });
|
||||
return delegate(userKey, computedRelation, objectKey, options);
|
||||
}
|
||||
}
|
||||
};
|
||||
return { arbiter, calls };
|
||||
}
|
||||
|
||||
describe('ComputedRule evaluation (rigor)', () => {
|
||||
it('result.possibility equals delegated authChecker.check result.possibility', async () => {
|
||||
async function check(userKey, computedRel, objectKey, possibility) {
|
||||
const { arbiter, calls } = makeArbiter(() => ({ possibility, reliability: 1.0 }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== possibility) {
|
||||
throw new Error(`result.possibility=${result.possibility}, expected ${possibility}`);
|
||||
}
|
||||
// Delegation must have happened with the rule.relation as the computed relation
|
||||
if (calls.length !== 1) throw new Error(`expected 1 authChecker.check call, got ${calls.length}`);
|
||||
if (calls[0].userKey !== userKey) throw new Error(`userKey=${calls[0].userKey}, expected ${userKey}`);
|
||||
if (calls[0].computedRelation !== computedRel) throw new Error(`computedRelation=${calls[0].computedRelation}, expected ${computedRel}`);
|
||||
if (calls[0].objectKey !== objectKey) throw new Error(`objectKey=${calls[0].objectKey}, expected ${objectKey}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-passthrough', ({ 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 === 'possibility-passthrough');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility passthrough violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.reason defaults to "computed_delegation" when delegated has no reason', async () => {
|
||||
async function check(userKey, computedRel, objectKey) {
|
||||
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{}
|
||||
);
|
||||
if (result.reason !== 'computed_delegation') {
|
||||
throw new Error(`reason=${result.reason}, expected 'computed_delegation'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('reason-default', ({ 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 === 'reason-default');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `reason default violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.reason passes through the delegated reason when present', async () => {
|
||||
async function check(userKey, computedRel, objectKey, reason) {
|
||||
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, reason }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{}
|
||||
);
|
||||
if (result.reason !== reason) {
|
||||
throw new Error(`reason=${result.reason}, expected ${reason}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(['direct_match', 'no_relation', 'inferred', 'chain_match', 'computed_delegation'])
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('reason-passthrough', ({ 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 === 'reason-passthrough');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `reason passthrough violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('meta.ruleType="computed" and meta.computedRelation=rule.relation', async () => {
|
||||
async function check(userKey, computedRel, objectKey) {
|
||||
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{ includeMeta: true, trackEvaluation: false }
|
||||
);
|
||||
if (!result.meta) throw new Error(`result.meta is missing (full result: ${JSON.stringify(result)})`);
|
||||
if (result.meta.ruleType !== 'computed') {
|
||||
throw new Error(`meta.ruleType=${result.meta.ruleType}, expected 'computed' (full meta: ${JSON.stringify(result.meta)})`);
|
||||
}
|
||||
if (result.meta.computedRelation !== computedRel) {
|
||||
throw new Error(`meta.computedRelation=${result.meta.computedRelation}, expected ${computedRel}`);
|
||||
}
|
||||
if (result.meta.delegated !== true) {
|
||||
throw new Error(`meta.delegated=${result.meta.delegated}, expected true`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('meta-contract', ({ 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 === 'meta-contract');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `meta contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('collectedValues pass through from delegated result', async () => {
|
||||
async function check(userKey, computedRel, objectKey, nValues) {
|
||||
const values = Array.from({ length: nValues }, (_, i) => ({
|
||||
value: i + 1,
|
||||
possibility: 0.5,
|
||||
path: [userKey, objectKey],
|
||||
source: { entityKey: userKey, relation: computedRel, step: 0 },
|
||||
metadata: { timestamp: 1000, reliability: 1.0 }
|
||||
}));
|
||||
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, collectedValues: values }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{}
|
||||
);
|
||||
if (result.collectedValues.length !== nValues) {
|
||||
throw new Error(`collectedValues.length=${result.collectedValues.length}, expected ${nValues}`);
|
||||
}
|
||||
for (let i = 0; i < nValues; i++) {
|
||||
if (result.collectedValues[i].value !== values[i].value) {
|
||||
throw new Error(`collectedValues[${i}].value=${result.collectedValues[i].value}, expected ${values[i].value}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.int(0, 5)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('collected-values-passthrough', ({ 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 === 'collected-values-passthrough');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `collected values passthrough violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility defaults to 0 when delegated has no possibility', async () => {
|
||||
async function check(userKey, computedRel, objectKey) {
|
||||
const { arbiter } = makeArbiter(() => ({ reliability: 1.0 })); // no possibility
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
new Set(),
|
||||
'unused',
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`result.possibility=${result.possibility}, expected 0 (fallback when delegated is undefined)`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-fallback-zero', ({ 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 === 'possibility-fallback-zero');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility fallback violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('authChecker.check is called with the visited set and currentRelation passed through options', async () => {
|
||||
async function check(userKey, computedRel, objectKey, currentRel) {
|
||||
const { arbiter, calls } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||||
const rule = new ComputedRule(arbiter);
|
||||
const visited = new Set([`visited:1`, `visited:2`]);
|
||||
rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'computed', relation: computedRel },
|
||||
visited,
|
||||
currentRel,
|
||||
{}
|
||||
);
|
||||
if (calls.length !== 1) throw new Error(`expected 1 call, got ${calls.length}`);
|
||||
if (!calls[0].hasVisited) throw new Error('authChecker.check did not receive options._visited');
|
||||
if (!calls[0].hasCurrentRel) throw new Error('authChecker.check did not receive options._currentRelation');
|
||||
return true;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(RELATIONS)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('options-passthrough', ({ 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 === 'options-passthrough');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `options passthrough violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* rigor/config-redefinition.test.js — js-rigor property tests for
|
||||
* setRelationConfig redefinition semantics.
|
||||
*
|
||||
* Redefining a relation's config must take effect immediately: checks
|
||||
* served from warm caches must reflect the NEW semantics (the direct-check
|
||||
* cache is keyed by the checked relation name and was previously never
|
||||
* invalidated by setRelationConfig — a direct r1 -> direct r2 redefinition
|
||||
* kept serving the r1 result until TTL expiry).
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - REDEFINE PARITY: after every redefinition round, checks equal the
|
||||
* twin arbiter built fresh with the final config (for every config
|
||||
* kind transition, multiple users, and warm caches).
|
||||
* - POST-REDEFINE MUTATIONS: mutations on the new base relations behave
|
||||
* normally after redefinition.
|
||||
*/
|
||||
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];
|
||||
const USERS = ['user:alice', 'user:bob'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildBase() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of ['user:alice', 'user:bob', 'group:eng', 'doc:1']) {
|
||||
arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc');
|
||||
}
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
arb.setRelationConfig('viewer', { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
function randomEdges(rng) {
|
||||
const edges = [];
|
||||
const pairs = [];
|
||||
for (const u of USERS) pairs.push([u, 'r1', 'doc:1'], [u, 'r2', 'doc:1']);
|
||||
pairs.push(['user:alice', 'member_of', 'group:eng'], ['user:bob', 'member_of', 'group:eng'], ['group:eng', 'viewer', 'doc:1']);
|
||||
for (const [src, rel, dst] of pairs) {
|
||||
if (rng.next() < 0.6) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges) {
|
||||
for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
}
|
||||
|
||||
// Config transition rounds: each round redefines 'can_access' with a new kind
|
||||
const ROUNDS = [
|
||||
{ type: 'direct', relation: 'r1' },
|
||||
{ type: 'direct', relation: 'r2' },
|
||||
{ type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' }] },
|
||||
{ union: [{ type: 'direct', relation: 'r1' }, { type: 'direct', relation: 'r2' }] },
|
||||
{ type: 'defeasible', when: { type: 'direct', relation: 'r1' }, unless: { type: 'direct', relation: 'r2' } },
|
||||
{ type: 'direct', relation: 'r1' }
|
||||
];
|
||||
|
||||
function checkAll(arb) {
|
||||
return USERS.map(u => arb.check(u, 'can_access', 'doc:1', {}).possibility);
|
||||
}
|
||||
|
||||
describe('Config redefinition semantics (rigor)', () => {
|
||||
it('REDEFINE PARITY: warm-cache checks match a fresh twin after every redefinition', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildBase();
|
||||
applyEdges(arb, edges);
|
||||
|
||||
// Round 0 config, warm the caches
|
||||
arb.setRelationConfig('can_access', ROUNDS[0]);
|
||||
checkAll(arb); // warm
|
||||
|
||||
for (let round = 1; round < ROUNDS.length; round++) {
|
||||
const config = ROUNDS[round];
|
||||
arb.setRelationConfig('can_access', config);
|
||||
|
||||
// Twin: fresh arbiter with the SAME final config and edges
|
||||
const twin = buildBase();
|
||||
twin.setRelationConfig('can_access', config);
|
||||
applyEdges(twin, edges);
|
||||
|
||||
const got = checkAll(arb);
|
||||
const expected = checkAll(twin);
|
||||
for (let i = 0; i < USERS.length; i++) {
|
||||
if (Math.abs(got[i] - expected[i]) > EPS) {
|
||||
fail(`round ${round} user ${USERS[i]}: redefined=${got[i]} twin=${expected[i]} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mutate a base relation after redefinition; parity with twin holds
|
||||
const rel = ['r1', 'r2'][Math.floor(rng.next() * 2)];
|
||||
const user = USERS[Math.floor(rng.next() * 2)];
|
||||
const idx = edges.findIndex(e => e[0] === user && e[1] === rel && e[2] === 'doc:1');
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(user, rel, 'doc:1');
|
||||
twin.removeRelation(user, rel, 'doc:1');
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(user, rel, 'doc:1', { possibility: p });
|
||||
twin.addRelation(user, rel, 'doc:1', { possibility: p });
|
||||
edges.push([user, rel, 'doc:1', p]);
|
||||
}
|
||||
const got2 = checkAll(arb);
|
||||
const expected2 = checkAll(twin);
|
||||
for (let i = 0; i < USERS.length; i++) {
|
||||
if (Math.abs(got2[i] - expected2[i]) > EPS) {
|
||||
fail(`round ${round} post-mutation user ${USERS[i]}: redefined=${got2[i]} twin=${expected2[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rounds: ROUNDS.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('redefine-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'config-redefinition-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'redefine-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `redefinition parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('BINARY AND FASTPATH follow redefinitions too', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildBase();
|
||||
applyEdges(arb, edges);
|
||||
|
||||
arb.setRelationConfig('can_access', ROUNDS[0]);
|
||||
checkAll(arb);
|
||||
|
||||
const config = { type: 'direct', relation: 'r2' };
|
||||
arb.setRelationConfig('can_access', config);
|
||||
const twin = buildBase();
|
||||
twin.setRelationConfig('can_access', config);
|
||||
applyEdges(twin, edges);
|
||||
|
||||
for (const u of USERS) {
|
||||
const b1 = arb.check(u, 'can_access', 'doc:1', { binary: true, minAllowPossibility: 0.5 });
|
||||
const b2 = twin.check(u, 'can_access', 'doc:1', { binary: true, minAllowPossibility: 0.5 });
|
||||
if (b1.allow !== b2.allow || Math.abs(b1.possibility - b2.possibility) > EPS) {
|
||||
fail(`binary divergence for ${u}: ${JSON.stringify(b1)} vs ${JSON.stringify(b2)}`);
|
||||
}
|
||||
const f1 = arb.check(u, 'can_access', 'doc:1', { fastPath: true, minAllowPossibility: 0.5 });
|
||||
const f2 = twin.check(u, 'can_access', 'doc:1', { fastPath: true, minAllowPossibility: 0.5 });
|
||||
if (Math.abs(f1.possibility - f2.possibility) > EPS) {
|
||||
fail(`fastPath divergence for ${u}: ${f1.possibility} vs ${f2.possibility}`);
|
||||
}
|
||||
}
|
||||
return { users: USERS.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 80000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('redefine-binary-fastpath', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'config-redefinition-binary' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'redefine-binary-fastpath');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `redefinition binary/fastPath parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* rigor/direct-rule.test.js — js-rigor property tests for DirectRule.
|
||||
*
|
||||
* DirectRule is the simplest authorization rule: it asks the relation manager
|
||||
* for a direct (src, rel, dst) tuple and returns a standardized result with
|
||||
* raw possibility values. Properties verified:
|
||||
*
|
||||
* - No relation → possibility=0, possibility_allow=0, possibility_deny=0,
|
||||
* meta.ruleType='direct', reason='no_relation'
|
||||
* - Relation present with strength s → possibility=s, possibility_allow=s,
|
||||
* possibility_deny=0, reason='exists'
|
||||
* - reverse=true routes the lookup to (objectId, rel, userId)
|
||||
* - fastPath with minPossibility threshold sets meta.earlyExit on hit
|
||||
* - collectValues=false suppresses collectedValues
|
||||
* - relation field precedence: rule.relation > rule.rel > rule.label >
|
||||
* rule.name > currentRelation
|
||||
* - possibility ∈ [0,1] is preserved through the result
|
||||
* - result shape is stable (always has the same keys)
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { DirectRule } from '../../src/authorization/rules/DirectRule.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||||
|
||||
/**
|
||||
* Build an arbiter stub whose relationManager.getDirectRelation returns
|
||||
* the value from a static relation table. Records calls for reverse/
|
||||
* non-reverse direction assertions.
|
||||
*/
|
||||
function makeArbiter(relations = {}) {
|
||||
const calls = [];
|
||||
const arbiter = {
|
||||
relationManager: {
|
||||
getDirectRelation(srcId, rel, dstId, options) {
|
||||
calls.push({ srcId, rel, dstId, reverse: options?.reverse });
|
||||
const key = `${srcId}|${rel}|${dstId}`;
|
||||
return relations[key] ?? null;
|
||||
}
|
||||
}
|
||||
};
|
||||
return { arbiter, calls };
|
||||
}
|
||||
|
||||
describe('DirectRule evaluation (rigor)', () => {
|
||||
it('returns possibility=0 with reason=no_relation when no direct relation exists', async () => {
|
||||
async function check(userId, objectId, relName) {
|
||||
const { arbiter } = makeArbiter({});
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== 0) throw new Error(`possibility=${result.possibility}, expected 0`);
|
||||
if (result.possibility_allow !== 0) throw new Error(`possibility_allow=${result.possibility_allow}, expected 0`);
|
||||
if (result.possibility_deny !== 0) throw new Error(`possibility_deny=${result.possibility_deny}, expected 0`);
|
||||
// DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
|
||||
// it to top-level result.reason); assert on the direct contract.
|
||||
if (!result.meta || result.meta.ruleType !== 'direct') {
|
||||
throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
|
||||
}
|
||||
if (result.meta.reason !== 'no_relation') {
|
||||
throw new Error(`meta.reason=${result.meta.reason}, expected 'no_relation'`);
|
||||
}
|
||||
if (result.collectedValues.length !== 0) {
|
||||
throw new Error(`expected no collected values, got ${result.collectedValues.length}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-relation-fallback', ({ 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 === 'no-relation-fallback');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-relation contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('returns relation strength as possibility/possibility_allow when present', async () => {
|
||||
async function check(userId, objectId, relName, strength) {
|
||||
const key = `${userId}|${relName}|${objectId}`;
|
||||
const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== strength) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${strength}`);
|
||||
}
|
||||
if (result.possibility_allow !== strength) {
|
||||
throw new Error(`possibility_allow=${result.possibility_allow}, expected ${strength}`);
|
||||
}
|
||||
if (result.possibility_deny !== 0) {
|
||||
throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
|
||||
}
|
||||
// DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
|
||||
// it to top-level result.reason); assert on the direct contract.
|
||||
if (!result.meta || result.meta.ruleType !== 'direct') {
|
||||
throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
|
||||
}
|
||||
if (result.meta.reason !== 'relation_exists') {
|
||||
throw new Error(`meta.reason=${result.meta.reason}, expected 'relation_exists'`);
|
||||
}
|
||||
// result.possibility should be in [0,1] (it is, by construction)
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('relation-strength-preserved', ({ 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 === 'relation-strength-preserved');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `relation-strength contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('reverse=true routes the lookup through (objectId, rel, userId)', async () => {
|
||||
async function check(userId, objectId, relName) {
|
||||
const key = `${objectId}|${relName}|${userId}`;
|
||||
const { arbiter, calls } = makeArbiter({ [key]: { possibility: 0.5 } });
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName, reverse: true },
|
||||
new Set(),
|
||||
relName,
|
||||
{}
|
||||
);
|
||||
// First call should have been (userId, rel, objectId) IF reverse=false;
|
||||
// since reverse=true, the call should be (objectId, rel, userId)
|
||||
const last = calls[calls.length - 1];
|
||||
if (last.srcId !== objectId || last.dstId !== userId) {
|
||||
throw new Error(`expected lookup (objectId, rel, userId)=(${objectId}, ${relName}, ${userId}), got (${last.srcId}, ${last.rel}, ${last.dstId})`);
|
||||
}
|
||||
// And the result should reflect the found relation
|
||||
if (result.possibility !== 0.5) {
|
||||
throw new Error(`reverse lookup should find relation strength 0.5, got ${result.possibility}`);
|
||||
}
|
||||
if (result.meta.reverse !== true) {
|
||||
throw new Error(`meta.reverse=${result.meta.reverse}, expected true`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('reverse-routing', ({ 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 === 'reverse-routing');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `reverse-routing contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('fastPath with minPossibility threshold sets meta.earlyExit on hit', async () => {
|
||||
async function check(userId, objectId, relName, strength, threshold) {
|
||||
const key = `${userId}|${relName}|${objectId}`;
|
||||
const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{ fastPath: true, minPossibility: threshold }
|
||||
);
|
||||
// If strength >= threshold, earlyExit should be set
|
||||
if (strength >= threshold) {
|
||||
if (!result.meta?.earlyExit) {
|
||||
throw new Error(`expected meta.earlyExit=true when strength=${strength} >= threshold=${threshold}, got ${JSON.stringify(result.meta)}`);
|
||||
}
|
||||
if (result.meta.earlyExitReason !== 'strength_threshold_met') {
|
||||
throw new Error(`expected earlyExitReason='strength_threshold_met', got '${result.meta.earlyExitReason}'`);
|
||||
}
|
||||
} else {
|
||||
if (result.meta?.earlyExit) {
|
||||
throw new Error(`did not expect earlyExit when strength=${strength} < threshold=${threshold}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('fastPath-early-exit', ({ 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 === 'fastPath-early-exit');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `fastPath early-exit violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('collectValues=false suppresses collected values even when relation has them', async () => {
|
||||
async function check(userId, objectId, relName) {
|
||||
const key = `${userId}|${relName}|${objectId}`;
|
||||
const { arbiter } = makeArbiter({ [key]: { possibility: 0.8, value: 42 } });
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{ collectValues: false }
|
||||
);
|
||||
if (result.collectedValues.length !== 0) {
|
||||
throw new Error(`expected no collected values when collectValues=false, got ${result.collectedValues.length}`);
|
||||
}
|
||||
// possibility is independent of collectValues
|
||||
if (result.possibility !== 0.8) {
|
||||
throw new Error(`possibility=${result.possibility}, expected 0.8`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('collectValues-disabled', ({ 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 === 'collectValues-disabled');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `collectValues=false violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('collectValues=true (default) includes collected value with full metadata', async () => {
|
||||
async function check(userId, objectId, relName, value) {
|
||||
const key = `${userId}|${relName}|${objectId}`;
|
||||
const { arbiter } = makeArbiter({ [key]: { possibility: 0.7, value, changed_last_at: 5000, source: 'persistent' } });
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{}
|
||||
);
|
||||
if (result.collectedValues.length !== 1) {
|
||||
throw new Error(`expected 1 collected value, got ${result.collectedValues.length}`);
|
||||
}
|
||||
const cv = result.collectedValues[0];
|
||||
if (cv.value !== value) throw new Error(`cv.value=${cv.value}, expected ${value}`);
|
||||
if (cv.possibility !== 0.7) throw new Error(`cv.possibility=${cv.possibility}, expected 0.7`);
|
||||
if (!Array.isArray(cv.path) || cv.path.length !== 2) {
|
||||
throw new Error(`cv.path malformed: ${JSON.stringify(cv.path)}`);
|
||||
}
|
||||
if (cv.path[0] !== `user:${userId}` || cv.path[1] !== `doc:${objectId}`) {
|
||||
throw new Error(`cv.path=${JSON.stringify(cv.path)}, expected [user:${userId}, doc:${objectId}]`);
|
||||
}
|
||||
if (cv.source.relation !== relName) {
|
||||
throw new Error(`cv.source.relation=${cv.source.relation}, expected ${relName}`);
|
||||
}
|
||||
if (cv.metadata.timestamp !== 5000) {
|
||||
throw new Error(`cv.metadata.timestamp=${cv.metadata.timestamp}, expected 5000`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.float({ min: 0, max: 1000 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('collectValues-default', ({ 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 === 'collectValues-default');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `collectValues default violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('relation field precedence: rule.relation beats rule.rel, label, name, currentRelation', async () => {
|
||||
// To verify precedence, we put a relation under each candidate name and assert
|
||||
// that DirectRule picks the highest-precedence one. When two candidates share
|
||||
// a name, the oracle can't distinguish them, so we skip those cases.
|
||||
async function check(userId, objectId, fromRule, fromRel, fromLabel, fromName, fromCurrent) {
|
||||
// Skip cases where any pair of candidates share a name — the test can't
|
||||
// distinguish precedence in that scenario
|
||||
const names = { rule: fromRule, rel: fromRel, label: fromLabel, name: fromName, current: fromCurrent };
|
||||
if (new Set(Object.values(names)).size !== 5) return null;
|
||||
|
||||
// Build a relations table that has the relation under EVERY candidate name.
|
||||
const { arbiter, calls } = makeArbiter({
|
||||
[`${userId}|${fromRule}|${objectId}`]: { possibility: 0.1 },
|
||||
[`${userId}|${fromRel}|${objectId}`]: { possibility: 0.2 },
|
||||
[`${userId}|${fromLabel}|${objectId}`]: { possibility: 0.3 },
|
||||
[`${userId}|${fromName}|${objectId}`]: { possibility: 0.4 },
|
||||
[`${userId}|${fromCurrent}|${objectId}`]: { possibility: 0.5 }
|
||||
});
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: fromRule, rel: fromRel, label: fromLabel, name: fromName },
|
||||
new Set(),
|
||||
fromCurrent,
|
||||
{}
|
||||
);
|
||||
// DirectRule should pick fromRule (highest precedence)
|
||||
if (result.possibility !== 0.1) {
|
||||
throw new Error(`expected possibility=0.1 (from rule.relation=${fromRule}), got ${result.possibility}`);
|
||||
}
|
||||
// The relationManager call should have been made with fromRule
|
||||
if (calls.length !== 1 || calls[0].rel !== fromRule) {
|
||||
throw new Error(`expected single call with rel=${fromRule}, got ${JSON.stringify(calls)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.enum(RELATIONS)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('relation-precedence', ({ 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 === 'relation-precedence');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `relation-precedence contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result shape is stable: all expected keys always present', async () => {
|
||||
async function check(userId, objectId, relName, hasRelation) {
|
||||
const key = `${userId}|${relName}|${objectId}`;
|
||||
const relations = hasRelation ? { [key]: { possibility: 0.6, value: 99 } } : {};
|
||||
const { arbiter } = makeArbiter(relations);
|
||||
const rule = new DirectRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
userId, `user:${userId}`,
|
||||
objectId, `doc:${objectId}`,
|
||||
{ type: 'direct', relation: relName },
|
||||
new Set(),
|
||||
relName,
|
||||
{}
|
||||
);
|
||||
// _createStandardResult guarantees these keys
|
||||
const expectedKeys = ['possibility', 'reliability', 'possibility_allow', 'possibility_deny', 'collectedValues', 'meta', 'meta_allow', 'meta_deny', 'remediation', 'reason'];
|
||||
for (const k of expectedKeys) {
|
||||
if (!(k in result)) {
|
||||
throw new Error(`result missing key '${k}' (full result: ${JSON.stringify(result)})`);
|
||||
}
|
||||
}
|
||||
// possibility_allow and possibility must equal each other in DirectRule
|
||||
if (result.possibility_allow !== result.possibility) {
|
||||
throw new Error(`possibility_allow (${result.possibility_allow}) !== possibility (${result.possibility})`);
|
||||
}
|
||||
// possibility_deny is always 0 in DirectRule
|
||||
if (result.possibility_deny !== 0) {
|
||||
throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
|
||||
}
|
||||
// reliability defaults to 1.0
|
||||
if (result.reliability !== 1.0) {
|
||||
throw new Error(`reliability=${result.reliability}, expected 1.0`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.int(0, 1000),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.boolean()
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-shape-stable', ({ 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 === 'result-shape-stable');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `result-shape contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* rigor/dsl-compiler.test.js — js-rigor property tests for the DSL compiler.
|
||||
*
|
||||
* Validates that ADR-000 Evidence DSL v2 compiles to the documented engine rule
|
||||
* types. ADR-000 §"Mapping to Engine Rule Types" enumerates:
|
||||
* - DirectRule → { type: 'direct' }
|
||||
* - TupleToUsersetRule → { type: 'tuple_to_userset' }
|
||||
* - ParentRule → { type: 'parent' }
|
||||
* - MultiHopRule → { type: 'multi_hop' }
|
||||
* - ChainRule → { type: 'chain' }
|
||||
* - LogicalOperators → { type: 'logical' }
|
||||
* - RelationalComparator → { type: 'relational_comparator' }
|
||||
*
|
||||
* Properties verified per rule type:
|
||||
* - DSL snippet compiles successfully
|
||||
* - Generated rule's `type` matches the ADR-000 mapping for that shape
|
||||
* - Required fields (relation, comparator, never/always/when, etc.) are present
|
||||
*
|
||||
* Bug class targeted: RF-24 — DSL compiler mapping gaps. The original generator
|
||||
* emitted 5 of the 7 ADR-000 rule types; ChainRule and RelationalComparatorRule
|
||||
* were dropped on the floor (silent gap, no test caught it).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { DSLCompiler } from '../../src/ast/DSLCompiler.js';
|
||||
|
||||
// Built-in types from lib/src/ast/validation/DSLPrelude.js are reserved (User, Account,
|
||||
// Device, AuthSession). Use non-reserved names so the validator accepts the DSL.
|
||||
const DEFINITIONS = `
|
||||
definition Person { id: string }
|
||||
definition Document { id: string }
|
||||
definition Group { id: string }
|
||||
definition Dept { id: string }
|
||||
`;
|
||||
|
||||
const FACTS = `
|
||||
fact owns(p: Person, d: Document)
|
||||
fact group_owner(g: Group, d: Document)
|
||||
fact member_of(p: Person, g: Group)
|
||||
fact parent_of(p: Document, c: Document)
|
||||
fact canReadInner(p: Person, d: Document)
|
||||
fact friend_of(a: Person, b: Person)
|
||||
fact works_in(p: Person, dept: Dept)
|
||||
fact has_access(dept: Dept, d: Document)
|
||||
fact isSuspended(p: Person)
|
||||
fact personAge(p: Person)
|
||||
fact docMinAge(d: Document)
|
||||
`;
|
||||
|
||||
/**
|
||||
* Build a fresh mock arbiter for each campaign so generated rules don't leak
|
||||
* between test cases.
|
||||
*/
|
||||
function makeMockArbiter() {
|
||||
const relationConfigs = new Map();
|
||||
return {
|
||||
relationConfigs,
|
||||
setRelationConfig(relation, config) {
|
||||
relationConfigs.set(relation, config);
|
||||
},
|
||||
registerDependencyIndex() { /* noop for rigor tests */ }
|
||||
};
|
||||
}
|
||||
|
||||
describe('DSLCompiler → engine rule mapping (rigor)', () => {
|
||||
it('DirectRule: simple predicate → type=direct', async () => {
|
||||
async function check(relationName) {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { ${relationName}(p, d) }\n`;
|
||||
const result = compiler.compile(dsl, `direct-${relationName}-${Math.random()}`);
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (!generated) {
|
||||
throw new Error(`no rule generated for 'canRead'`);
|
||||
}
|
||||
if (generated.type !== 'direct') {
|
||||
throw new Error(`expected type='direct' for simple predicate call, got '${generated.type}' (relation=${relationName})`);
|
||||
}
|
||||
if (generated.relation !== relationName) {
|
||||
throw new Error(`expected relation='${relationName}', got '${generated.relation}'`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
// Generator draws from declared facts with a (Person, Document)
|
||||
// signature — the DSL validator rejects undeclared predicates.
|
||||
[rigor.fn('check', check, rigor.args(rigor.gen.oneOf(['owns', 'canReadInner'])))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('direct-emission', ({ 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 === 'direct-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `direct emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('TupleToUsersetRule: membership predicate → type=tuple_to_userset', async () => {
|
||||
async function check() {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
// ADR-000 shape: outer predicate + inner membership predicate. Use member_of
|
||||
// as OUTER so the existing isMembershipPredicate dispatch routes to TUS.
|
||||
// Inner must be type-valid: group_owner(g: Group, d: Document).
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { member_of(p, *g) { group_owner(g, d) } limit 5 }\n`;
|
||||
const result = compiler.compile(dsl, 'tus');
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (generated.type !== 'tuple_to_userset') {
|
||||
throw new Error(`expected type='tuple_to_userset', got '${generated.type}'`);
|
||||
}
|
||||
if (!generated.tuplesetRelation || !generated.computedRelation) {
|
||||
throw new Error(`tuple_to_userset missing tuplesetRelation/computedRelation: ${JSON.stringify(generated)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('tus-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `tuple_to_userset emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('ParentRule: hierarchy predicate → type=parent', async () => {
|
||||
async function check() {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { parent_of(*parent, d) { canReadInner(p, parent) } limit 3 }\n`;
|
||||
const result = compiler.compile(dsl, 'parent');
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (generated.type !== 'parent') {
|
||||
throw new Error(`expected type='parent', got '${generated.type}'`);
|
||||
}
|
||||
if (!generated.parentRelation) {
|
||||
throw new Error(`parent rule missing parentRelation: ${JSON.stringify(generated)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('parent-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `parent emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('ChainRule: nested predicate-call pattern → type=chain (RF-24)', async () => {
|
||||
async function check() {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
// ADR-000 chain shape: "works_in(p, *d) { has_access(d, r) }"
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { works_in(p, *dept) { has_access(dept, d) } }\n`;
|
||||
const result = compiler.compile(dsl, 'chain');
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (generated.type !== 'chain') {
|
||||
throw new Error(`expected type='chain', got '${generated.type}' (chain rule is missing from the compiler)`);
|
||||
}
|
||||
if (!Array.isArray(generated.steps) || generated.steps.length < 2) {
|
||||
throw new Error(`chain rule must have at least 2 steps, got ${JSON.stringify(generated.steps)}`);
|
||||
}
|
||||
// Steps must reference both predicates from the DSL
|
||||
if (!generated.steps.includes('works_in') || !generated.steps.includes('has_access')) {
|
||||
throw new Error(`chain steps should include 'works_in' and 'has_access', got ${JSON.stringify(generated.steps)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('chain-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `chain emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('MultiHopRule: collection-processing with |var| → type=multi_hop', async () => {
|
||||
async function check() {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
// ADR-000 multi-hop shape via collection-processing: friend_of(a, f) { friend_of(f, b) } limit 5
|
||||
// NOTE: a wildcard intermediate (*f) is classified as a CHAIN by the
|
||||
// current dispatch (chain detection precedes multi_hop), so the
|
||||
// multi_hop shape uses a plain variable binding instead. We bypass the
|
||||
// validator by directly exercising the parser→generator path: parse
|
||||
// only, then run the generator against the parsed AST.
|
||||
const { parse } = await import('../../src/ast/parser/DSLParser.js');
|
||||
const { RuleGenerator } = await import('../../src/ast/generator/RuleGenerator.js');
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canReach(a: Person, b: Person) { friend_of(a, f) { friend_of(f, b) } limit 5 }\n`;
|
||||
const program = parse(dsl);
|
||||
const generator = new RuleGenerator(arbiter);
|
||||
const programNode = {
|
||||
definitions: program.body.filter(s => s.type === 'Definition'),
|
||||
facts: program.body.filter(s => s.type === 'Fact'),
|
||||
evidence: program.body.filter(s => s.type === 'Evidence'),
|
||||
measures: program.body.filter(s => s.type === 'Measure')
|
||||
};
|
||||
const genResult = generator.generateRules(programNode);
|
||||
if (!genResult.success) {
|
||||
throw new Error(`generator failed: ${genResult.errors.join('; ')}`);
|
||||
}
|
||||
const generated = generator.getGeneratedRules().get('canReach');
|
||||
if (!generated) throw new Error('no rule generated for canReach');
|
||||
if (generated.type !== 'multi_hop') {
|
||||
throw new Error(`expected type='multi_hop', got '${generated.type}'`);
|
||||
}
|
||||
if (!generated.relation) {
|
||||
throw new Error(`multi_hop rule missing relation: ${JSON.stringify(generated)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi_hop-emission', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi_hop-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `multi_hop emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('LogicalOperators: NEVER/ALWAYS/REQUIRES → type=logical with right level', async () => {
|
||||
async function check(pair) {
|
||||
// pair is [level, keyword] — keeps the two correlated
|
||||
const [level, keyword] = pair;
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { ${keyword} isSuspended(p) }\n`;
|
||||
const result = compiler.compile(dsl, `logical-${level}-${Math.random()}`);
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (generated.type !== 'logical') {
|
||||
throw new Error(`expected type='logical' for ${keyword}, got '${generated.type}'`);
|
||||
}
|
||||
if (!generated[level]) {
|
||||
throw new Error(`logical rule missing '${level}' block (expected under keyword=${keyword}): ${JSON.stringify(generated)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.constant(['never', 'NEVER']),
|
||||
rigor.gen.constant(['always', 'ALWAYS']),
|
||||
rigor.gen.constant(['requires', 'REQUIRES'])
|
||||
])
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('logical-emission', ({ 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 === 'logical-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `logical emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('RelationalComparatorRule: comparison → type=relational_comparator (RF-24)', async () => {
|
||||
async function check(op) {
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const dsl = `${DEFINITIONS}${FACTS}\nevidence canRead(p: Person, d: Document) { personAge(p) ${op} docMinAge(d) }\n`;
|
||||
const result = compiler.compile(dsl, `rc-${op}-${Math.random()}`);
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const generated = result.generatedRules.get('canRead');
|
||||
if (generated.type !== 'relational_comparator') {
|
||||
throw new Error(`expected type='relational_comparator' for op='${op}', got '${generated.type}'`);
|
||||
}
|
||||
if (generated.comparator !== op) {
|
||||
throw new Error(`expected comparator='${op}', got '${generated.comparator}'`);
|
||||
}
|
||||
if (!generated.left || !generated.right) {
|
||||
throw new Error(`comparator missing left/right operand: ${JSON.stringify(generated)}`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(rigor.gen.enum(['>', '>=', '<', '<=', '==', '!='])))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('relational-comparator-emission', ({ 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 === 'relational-comparator-emission');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `relational_comparator emission violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('mapping consistency: each ADR-000 mapping emits exactly one of the documented types', async () => {
|
||||
// Property sweep: a small catalog of DSL snippets, each tagged with the
|
||||
// expected rule type per ADR-000. Catch future regressions where someone
|
||||
// re-routes through `logical` (or any other type) by accident.
|
||||
// NOTE: multi_hop is intentionally absent — the validator cannot
|
||||
// type-infer its shape (see the dedicated MultiHopRule test above, which
|
||||
// bypasses the validator via the parser→generator path).
|
||||
const catalog = [
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { owns(p, d) }', expectedType: 'direct' },
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { member_of(p, *g) { group_owner(g, d) } limit 5 }', expectedType: 'tuple_to_userset' },
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { parent_of(*parent, d) { canReadInner(p, parent) } limit 3 }', expectedType: 'parent' },
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { works_in(p, *dept) { has_access(dept, d) } }', expectedType: 'chain' },
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { NEVER isSuspended(p) }', expectedType: 'logical' },
|
||||
{ dsl: 'evidence x(p: Person, d: Document) { personAge(p) >= docMinAge(d) }', expectedType: 'relational_comparator' }
|
||||
];
|
||||
|
||||
async function check(idx) {
|
||||
const entry = catalog[idx];
|
||||
const arbiter = makeMockArbiter();
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const result = compiler.compile(DEFINITIONS + FACTS + entry.dsl, `cat-${idx}-${Math.random()}`);
|
||||
if (!result.success) {
|
||||
throw new Error(`compile failed for catalog[${idx}]: ${result.errors.join('; ')}`);
|
||||
}
|
||||
const evidenceName = entry.dsl.match(/evidence\s+(\w+)/)[1];
|
||||
const generated = result.generatedRules.get(evidenceName);
|
||||
if (!generated) {
|
||||
throw new Error(`no rule generated for '${evidenceName}' (catalog[${idx}])`);
|
||||
}
|
||||
if (generated.type !== entry.expectedType) {
|
||||
throw new Error(`catalog[${idx}]: expected type='${entry.expectedType}', got '${generated.type}'`);
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(rigor.gen.int(0, catalog.length - 1)))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('mapping-consistency', ({ 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 === 'mapping-consistency');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `mapping consistency violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* rigor/dsl-mutation-parity.test.js — js-rigor property tests that a
|
||||
* DSL-compiled arbiter and an equivalent hand-written arbiter stay in
|
||||
* lock-step through identical MUTATION SEQUENCES.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - SEQUENCE PARITY: starting from the same graph, apply the same random
|
||||
* add/remove sequence to both the DSL-compiled and the hand-written
|
||||
* arbiter; after EVERY mutation the check() answers agree exactly.
|
||||
* This exercises compiled configs (dependency indexes, caches,
|
||||
* invalidation) under mutation, not just static evaluation.
|
||||
* - GRANT/REVOKE CYCLES: interleaved add/remove of the same tuple keeps
|
||||
* both arbiters consistent (no stale compiled state).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { DSLCompiler } from '../../src/ast/DSLCompiler.js';
|
||||
|
||||
const EPS = 1e-9;
|
||||
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const DSL = `
|
||||
definition Doc { id: string }
|
||||
definition Dept { id: string }
|
||||
fact owns(user: User, doc: Doc)
|
||||
fact works_in(user: User, dept: Dept)
|
||||
fact has_access(dept: Dept, doc: Doc)
|
||||
evidence can_read(user: User, doc: Doc) { owns(user, doc) }
|
||||
evidence can_access(user: User, doc: Doc) { works_in(user, *d) { has_access(d, doc) } }
|
||||
`;
|
||||
|
||||
function buildCompiledArbiter() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('dept:eng', 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
const compiler = new DSLCompiler(arbiter);
|
||||
const result = compiler.compile(DSL, 'mutation-parity');
|
||||
if (!result.success) {
|
||||
throw new Error(`DSL compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
function buildManualArbiter() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('dept:eng', 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owns' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'works_in', direction: 'out' },
|
||||
{ relation: 'has_access', direction: 'out' }
|
||||
]
|
||||
});
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
const RELATIONS = ['owns', 'works_in', 'has_access'];
|
||||
const SUBJECTS = ['user:alice', 'dept:eng'];
|
||||
|
||||
function applyMutation(arbiter, op) {
|
||||
const [kind, src, rel, dst, p] = op;
|
||||
if (kind === 'add') {
|
||||
arbiter.addRelation(src, rel, dst, { possibility: p });
|
||||
} else {
|
||||
arbiter.removeRelation(src, rel, dst);
|
||||
}
|
||||
}
|
||||
|
||||
describe('DSL-compiled vs hand-written parity under mutation (rigor)', () => {
|
||||
it('SEQUENCE PARITY: identical mutation sequences keep compiled and manual arbiters in lock-step', async () => {
|
||||
async function check(operations) {
|
||||
const compiled = buildCompiledArbiter();
|
||||
const manual = buildManualArbiter();
|
||||
|
||||
// Same starting graph on both
|
||||
for (const arb of [compiled, manual]) {
|
||||
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: 0.5 });
|
||||
arb.addRelation('user:alice', 'works_in', 'dept:eng', { possibility: 1 });
|
||||
arb.addRelation('dept:eng', 'has_access', 'doc:1', { possibility: 0.75 });
|
||||
}
|
||||
|
||||
for (const op of operations) {
|
||||
applyMutation(compiled, op);
|
||||
applyMutation(manual, op);
|
||||
|
||||
for (const rel of ['can_read', 'can_access']) {
|
||||
const c = compiled.check('user:alice', rel, 'doc:1');
|
||||
const m = manual.check('user:alice', rel, 'doc:1');
|
||||
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
||||
fail(`parity ${rel} after ${JSON.stringify(op)}: compiled=${c.possibility}, manual=${m.possibility}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ops: operations.length };
|
||||
}
|
||||
|
||||
const mutationGen = rigor.gen.oneOf([
|
||||
rigor.gen.tuple(
|
||||
rigor.gen.constant('add'),
|
||||
rigor.gen.oneOf(SUBJECTS),
|
||||
rigor.gen.oneOf(RELATIONS),
|
||||
rigor.gen.oneOf(SUBJECTS),
|
||||
rigor.gen.oneOf(POS)
|
||||
),
|
||||
rigor.gen.tuple(
|
||||
rigor.gen.constant('remove'),
|
||||
rigor.gen.oneOf(SUBJECTS),
|
||||
rigor.gen.oneOf(RELATIONS),
|
||||
rigor.gen.oneOf(SUBJECTS),
|
||||
rigor.gen.constant(0)
|
||||
)
|
||||
]);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.array(mutationGen, 1, 10)
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('sequence-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'dsl-mutation-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'sequence-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `SEQUENCE PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('GRANT/REVOKE CYCLES: repeated add/remove of the same tuple never desyncs', async () => {
|
||||
async function check({ cycles, p }) {
|
||||
const compiled = buildCompiledArbiter();
|
||||
const manual = buildManualArbiter();
|
||||
|
||||
for (let i = 0; i < cycles; i++) {
|
||||
for (const arb of [compiled, manual]) {
|
||||
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: p });
|
||||
}
|
||||
for (const rel of ['can_read', 'can_access']) {
|
||||
const c = compiled.check('user:alice', rel, 'doc:1');
|
||||
const m = manual.check('user:alice', rel, 'doc:1');
|
||||
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
||||
fail(`grant cycle ${i} ${rel}: compiled=${c.possibility}, manual=${m.possibility}`);
|
||||
}
|
||||
}
|
||||
for (const arb of [compiled, manual]) {
|
||||
arb.removeRelation('user:alice', 'owns', 'doc:1');
|
||||
}
|
||||
const c = compiled.check('user:alice', 'can_read', 'doc:1');
|
||||
const m = manual.check('user:alice', 'can_read', 'doc:1');
|
||||
if (Math.abs(c.possibility - m.possibility) > EPS) {
|
||||
fail(`revoke cycle ${i}: compiled=${c.possibility}, manual=${m.possibility}`);
|
||||
}
|
||||
if (c.possibility !== 0) {
|
||||
fail(`revoke cycle ${i}: grant survived removal (${c.possibility})`);
|
||||
}
|
||||
}
|
||||
return { cycles };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
cycles: rigor.gen.int(2, 8),
|
||||
p: rigor.gen.oneOf([0.25, 0.5, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('grant-revoke-cycles', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'dsl-grant-revoke-cycles' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'grant-revoke-cycles');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `GRANT/REVOKE CYCLES violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* rigor/graph-indices.test.js — js-rigor property tests for GraphIndices.
|
||||
*
|
||||
* GraphIndices maintains five indexes over the relation set:
|
||||
* - relationsBySrcRelDst: Map<key, relationObj> — direct lookup (src,rel,dst) → rel
|
||||
* - relationsBySrcRel: Map<key, Set<relationObj>> — all relations from src under rel
|
||||
* - relationsByDstRel: Map<key, Set<relationObj>> — all relations to dst under rel
|
||||
* - relationsByRel: Map<rel, Set<relationObj>> — all relations under a name
|
||||
* - outgoingEdges: Map<srcId, dstId[]> — adjacency (with duplicates)
|
||||
* - incomingEdges: Map<dstId, srcId[]> — reverse adjacency
|
||||
*
|
||||
* Properties verified against a brute-force oracle (two naive Maps):
|
||||
* - getDirectRelation(src, rel, dst) matches the oracle
|
||||
* - getRelationsFromSrc(src, rel) returns the exact set the oracle records
|
||||
* - getRelationsToDst(dst, rel) returns the exact set the oracle records
|
||||
* - getRelationsByName(rel) returns the exact set the oracle records
|
||||
* - After add+remove cycle, getDirectRelation returns undefined
|
||||
* - Adding the same relation twice is idempotent (Set semantics)
|
||||
* - clear() empties every index
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { GraphIndices } from '../../src/core/GraphIndices.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent', 'admin'];
|
||||
|
||||
/**
|
||||
* Build a relation object with optional strength metadata.
|
||||
*/
|
||||
function relObj(src, rel, dst, possibility = 1.0, value = undefined) {
|
||||
const r = { src, rel, dst, possibility };
|
||||
if (value !== undefined) r.value = value;
|
||||
r.changed_last_at = 1000;
|
||||
r.updated_last_at = 1000;
|
||||
r.source = 'persistent';
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brute-force oracle that mirrors GraphIndices' production contract.
|
||||
* After RF-22, addRelation enforces (src, rel, dst) uniqueness and replaces
|
||||
* any existing entry — both in the composite-key index AND in the Set-backed
|
||||
* indexes. The oracle mirrors that exactly:
|
||||
* - direct: Map<"src|rel|dst", relObj> — last writer wins
|
||||
* - byName: Map<rel, Set<relObj>> — Set by reference identity
|
||||
* - bySrc: Map<"src|rel", Set<relObj>>
|
||||
* - byDst: Map<"dst|rel", Set<relObj>>
|
||||
*
|
||||
* add(r): if (src,rel,dst) is new, insert into all four. If duplicate, replace
|
||||
* in `direct` AND swap the old ref out of all Sets before adding the new.
|
||||
* remove(r, opSrc, opDst, opRel): lookup by the (opSrc, opRel, opDst) args
|
||||
* (production uses these, not the relObj's fields), then delete from all.
|
||||
*/
|
||||
function makeOracle() {
|
||||
const direct = new Map();
|
||||
const byName = new Map();
|
||||
const bySrc = new Map();
|
||||
const byDst = new Map();
|
||||
const outgoingEdges = new Map();
|
||||
const incomingEdges = new Map();
|
||||
|
||||
function key(a, b, c) {
|
||||
return c !== undefined ? `${a}|${b}|${c}` : `${a}|${b}`;
|
||||
}
|
||||
|
||||
function add(r) {
|
||||
const directKey = key(r.src, r.rel, r.dst);
|
||||
const existing = direct.get(directKey);
|
||||
if (existing && existing !== r) {
|
||||
// Replace: remove existing from all Sets, then insert r
|
||||
const srcRel = key(existing.src, existing.rel);
|
||||
const dstRel = key(existing.dst, existing.rel);
|
||||
byName.get(existing.rel)?.delete(existing);
|
||||
if (byName.get(existing.rel)?.size === 0) byName.delete(existing.rel);
|
||||
bySrc.get(srcRel)?.delete(existing);
|
||||
if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel);
|
||||
byDst.get(dstRel)?.delete(existing);
|
||||
if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel);
|
||||
}
|
||||
if (!existing) {
|
||||
// New: also update outgoingEdges / incomingEdges (production appends on every add)
|
||||
if (!outgoingEdges.has(r.src)) outgoingEdges.set(r.src, []);
|
||||
outgoingEdges.get(r.src).push(r.dst);
|
||||
if (!incomingEdges.has(r.dst)) incomingEdges.set(r.dst, []);
|
||||
incomingEdges.get(r.dst).push(r.src);
|
||||
}
|
||||
direct.set(directKey, r);
|
||||
if (!byName.has(r.rel)) byName.set(r.rel, new Set());
|
||||
byName.get(r.rel).add(r);
|
||||
const srcRel = key(r.src, r.rel);
|
||||
if (!bySrc.has(srcRel)) bySrc.set(srcRel, new Set());
|
||||
bySrc.get(srcRel).add(r);
|
||||
const dstRel = key(r.dst, r.rel);
|
||||
if (!byDst.has(dstRel)) byDst.set(dstRel, new Set());
|
||||
byDst.get(dstRel).add(r);
|
||||
}
|
||||
|
||||
function remove(r, opSrc, opDst, opRel) {
|
||||
// Production uses the args (srcId, dstId, relation) for the composite key
|
||||
const srcId = opSrc !== undefined ? opSrc : r.src;
|
||||
const dstId = opDst !== undefined ? opDst : r.dst;
|
||||
const rel = opRel !== undefined ? opRel : r.rel;
|
||||
const directKey = key(srcId, rel, dstId);
|
||||
const stored = direct.get(directKey);
|
||||
if (!stored) return;
|
||||
direct.delete(directKey);
|
||||
const srcRel = key(stored.src, stored.rel);
|
||||
const dstRel = key(stored.dst, stored.rel);
|
||||
byName.get(stored.rel)?.delete(stored);
|
||||
if (byName.get(stored.rel)?.size === 0) byName.delete(stored.rel);
|
||||
bySrc.get(srcRel)?.delete(stored);
|
||||
if (bySrc.get(srcRel)?.size === 0) bySrc.delete(srcRel);
|
||||
byDst.get(dstRel)?.delete(stored);
|
||||
if (byDst.get(dstRel)?.size === 0) byDst.delete(dstRel);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
direct.clear();
|
||||
byName.clear();
|
||||
bySrc.clear();
|
||||
byDst.clear();
|
||||
outgoingEdges.clear();
|
||||
incomingEdges.clear();
|
||||
}
|
||||
|
||||
function getDirect(src, rel, dst) { return direct.get(key(src, rel, dst)); }
|
||||
function getByName(rel) { return Array.from(byName.get(rel) ?? []); }
|
||||
function getBySrc(src, rel) { return Array.from(bySrc.get(key(src, rel)) ?? []); }
|
||||
function getByDst(dst, rel) { return Array.from(byDst.get(key(dst, rel)) ?? []); }
|
||||
|
||||
return { add, remove, clear, getDirect, getByName, getBySrc, getByDst, direct, byName, bySrc, byDst, outgoingEdges, incomingEdges };
|
||||
}
|
||||
|
||||
describe('GraphIndices indexes (rigor)', () => {
|
||||
it('getDirectRelation matches the brute-force oracle', async () => {
|
||||
async function check(operations) {
|
||||
const gi = new GraphIndices();
|
||||
const oracle = makeOracle();
|
||||
// op codes: 0=add, 1=remove, 2=query
|
||||
for (const op of operations) {
|
||||
if (op[0] === 0) {
|
||||
gi.addRelation(op[1]);
|
||||
oracle.add(op[1]);
|
||||
} else if (op[0] === 1) {
|
||||
gi.removeRelation(op[1], op[2], op[3], op[4]);
|
||||
// Mirror production's key construction exactly: it looks up by the
|
||||
// (srcId, dstId, relation) ARGS, not the relObj's own fields.
|
||||
oracle.remove(op[1], op[2], op[3], op[4]);
|
||||
} else if (op[0] === 2) {
|
||||
const [, src, rel, dst] = op;
|
||||
const actual = gi.getDirectRelation(src, rel, dst);
|
||||
const expected = oracle.getDirect(src, rel, dst);
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`getDirectRelation(${src},${rel},${dst}): actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.tuple(rigor.gen.constant(0), relGen),
|
||||
rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
|
||||
rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 5))
|
||||
]),
|
||||
1, 12
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(opGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('getDirectRelation-matches-oracle', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'graph-indices-direct-a' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-matches-oracle');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `getDirectRelation diverged from oracle in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('getRelationsFromSrc matches the brute-force oracle', async () => {
|
||||
async function check(operations) {
|
||||
const gi = new GraphIndices();
|
||||
const oracle = makeOracle();
|
||||
for (const op of operations) {
|
||||
if (op[0] === 0) {
|
||||
gi.addRelation(op[1]);
|
||||
oracle.add(op[1]);
|
||||
} else if (op[0] === 1) {
|
||||
gi.removeRelation(op[1], op[2], op[3], op[4]);
|
||||
oracle.remove(op[1], op[2], op[3], op[4]);
|
||||
} else if (op[0] === 2) {
|
||||
const [, src, rel] = op;
|
||||
const actual = gi.getRelationsFromSrc(src, rel);
|
||||
const expected = oracle.getBySrc(src, rel);
|
||||
// Both should be Sets — compare content
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`getRelationsFromSrc(${src},${rel}): length actual=${actual.length} expected=${expected.length}`
|
||||
);
|
||||
}
|
||||
const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
if (actualKeys.size !== expectedKeys.size ||
|
||||
![...actualKeys].every(k => expectedKeys.has(k))) {
|
||||
throw new Error(
|
||||
`getRelationsFromSrc(${src},${rel}): content mismatch`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.tuple(rigor.gen.constant(0), relGen),
|
||||
rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
|
||||
rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS))
|
||||
]),
|
||||
1, 12
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(opGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('getRelationsFromSrc-matches-oracle', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'graph-indices-direct-b' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsFromSrc-matches-oracle');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `getRelationsFromSrc diverged from oracle in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('getRelationsToDst matches the brute-force oracle', async () => {
|
||||
async function check(operations) {
|
||||
const gi = new GraphIndices();
|
||||
const oracle = makeOracle();
|
||||
for (const op of operations) {
|
||||
if (op[0] === 0) {
|
||||
gi.addRelation(op[1]);
|
||||
oracle.add(op[1]);
|
||||
} else if (op[0] === 1) {
|
||||
gi.removeRelation(op[1], op[2], op[3], op[4]);
|
||||
oracle.remove(op[1], op[2], op[3], op[4]);
|
||||
} else if (op[0] === 2) {
|
||||
const [, dst, rel] = op;
|
||||
const actual = gi.getRelationsToDst(dst, rel);
|
||||
const expected = oracle.getByDst(dst, rel);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`getRelationsToDst(${dst},${rel}): length actual=${actual.length} expected=${expected.length}`
|
||||
);
|
||||
}
|
||||
const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
if (actualKeys.size !== expectedKeys.size ||
|
||||
![...actualKeys].every(k => expectedKeys.has(k))) {
|
||||
throw new Error(`getRelationsToDst(${dst},${rel}): content mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.tuple(rigor.gen.constant(0), relGen),
|
||||
rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
|
||||
rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS))
|
||||
]),
|
||||
1, 12
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(opGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('getRelationsToDst-matches-oracle', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'graph-indices-direct-c' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsToDst-matches-oracle');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `getRelationsToDst diverged from oracle in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('getRelationsByName matches the brute-force oracle', async () => {
|
||||
async function check(operations) {
|
||||
const gi = new GraphIndices();
|
||||
const oracle = makeOracle();
|
||||
for (const op of operations) {
|
||||
if (op[0] === 0) {
|
||||
gi.addRelation(op[1]);
|
||||
oracle.add(op[1]);
|
||||
} else if (op[0] === 1) {
|
||||
gi.removeRelation(op[1], op[2], op[3], op[4]);
|
||||
oracle.remove(op[1], op[2], op[3], op[4]);
|
||||
} else if (op[0] === 2) {
|
||||
const [, rel] = op;
|
||||
const actual = gi.getRelationsByName(rel);
|
||||
const expected = oracle.getByName(rel);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`getRelationsByName(${rel}): length actual=${actual.length} expected=${expected.length}`
|
||||
);
|
||||
}
|
||||
const actualKeys = new Set(actual.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
const expectedKeys = new Set(expected.map(r => `${r.src}|${r.rel}|${r.dst}`));
|
||||
if (actualKeys.size !== expectedKeys.size ||
|
||||
![...actualKeys].every(k => expectedKeys.has(k))) {
|
||||
throw new Error(`getRelationsByName(${rel}): content mismatch`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.tuple(rigor.gen.constant(0), relGen),
|
||||
rigor.gen.tuple(rigor.gen.constant(1), relGen, rigor.gen.int(0, 5), rigor.gen.int(0, 5), rigor.gen.enum(RELATIONS)),
|
||||
rigor.gen.tuple(rigor.gen.constant(2), rigor.gen.enum(RELATIONS))
|
||||
]),
|
||||
1, 12
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(opGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('getRelationsByName-matches-oracle', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'graph-indices-direct-d' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getRelationsByName-matches-oracle');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `getRelationsByName diverged from oracle in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('addRelation is idempotent for same (src,rel,dst) regardless of object identity (RF-22)', async () => {
|
||||
// RF-22 closure regression test. Two distinct objects sharing the same
|
||||
// (src, rel, dst) tuple must not produce two entries in the by-rel/
|
||||
// by-src-rel/by-dst-rel indexes — relationsBySrcRelDst's composite-key
|
||||
// dedup is the canonical invariant.
|
||||
async function check(src, rel, dst, possibility1, possibility2) {
|
||||
const gi = new GraphIndices();
|
||||
const r1 = relObj(src, rel, dst, possibility1);
|
||||
const r2 = relObj(src, rel, dst, possibility2);
|
||||
gi.addRelation(r1);
|
||||
gi.addRelation(r2);
|
||||
|
||||
// direct lookup returns the FIRST (last-writer-wins on the composite key
|
||||
// means the second call overwrites, so r2 should be returned)
|
||||
const direct = gi.getDirectRelation(src, rel, dst);
|
||||
if (direct !== r2) {
|
||||
throw new Error(`getDirectRelation should return r2, got ${JSON.stringify(direct)}`);
|
||||
}
|
||||
|
||||
// byName/bySrcRel/byDstRel must contain only r2 (the surviving entry)
|
||||
const byName = gi.getRelationsByName(rel);
|
||||
if (byName.length !== 1 || byName[0] !== r2) {
|
||||
throw new Error(`getRelationsByName should return only r2, got ${byName.length} entries`);
|
||||
}
|
||||
const bySrc = gi.getRelationsFromSrc(src, rel);
|
||||
if (bySrc.length !== 1 || bySrc[0] !== r2) {
|
||||
throw new Error(`getRelationsFromSrc should return only r2, got ${bySrc.length} entries`);
|
||||
}
|
||||
const byDst = gi.getRelationsToDst(dst, rel);
|
||||
if (byDst.length !== 1 || byDst[0] !== r2) {
|
||||
throw new Error(`getRelationsToDst should return only r2, got ${byDst.length} entries`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 10),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 10),
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('addRelation-tuple-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'graph-indices-src' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-tuple-idempotent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `RF-22 idempotence violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('addRelation is idempotent (Set semantics, same obj not added twice)', async () => {
|
||||
async function check(r) {
|
||||
const gi = new GraphIndices();
|
||||
gi.addRelation(r);
|
||||
const before = gi.relationsByRel.get(r.rel)?.size ?? 0;
|
||||
gi.addRelation(r); // same object again
|
||||
const after = gi.relationsByRel.get(r.rel)?.size ?? 0;
|
||||
if (before !== after) {
|
||||
throw new Error(`addRelation not idempotent: before=${before} after=${after}`);
|
||||
}
|
||||
// direct lookup should return the same object
|
||||
const a = gi.getDirectRelation(r.src, r.rel, r.dst);
|
||||
if (a !== r) {
|
||||
throw new Error(`getDirectRelation returned different object identity`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(relGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('addRelation-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'graph-indices-dst' });
|
||||
|
||||
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('clear empties every index', async () => {
|
||||
async function check(rels) {
|
||||
const gi = new GraphIndices();
|
||||
for (const r of rels) gi.addRelation(r);
|
||||
gi.clear();
|
||||
if (gi.relationsBySrcRelDst.size !== 0) throw new Error('relationsBySrcRelDst not empty');
|
||||
if (gi.relationsBySrcRel.size !== 0) throw new Error('relationsBySrcRel not empty');
|
||||
if (gi.relationsByDstRel.size !== 0) throw new Error('relationsByDstRel not empty');
|
||||
if (gi.relationsByRel.size !== 0) throw new Error('relationsByRel not empty');
|
||||
if (gi.outgoingEdges.size !== 0) throw new Error('outgoingEdges not empty');
|
||||
if (gi.incomingEdges.size !== 0) throw new Error('incomingEdges not empty');
|
||||
// keyManager should be cleared too — re-adding same rel returns different id only if cleared
|
||||
// Actually re-adding after clear should still work
|
||||
gi.addRelation(rels[0]);
|
||||
if (!gi.getDirectRelation(rels[0].src, rels[0].rel, rels[0].dst)) {
|
||||
throw new Error('cannot re-add after clear');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.array(
|
||||
rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p)),
|
||||
1, 8
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(relGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('clear-empties-indexes', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'graph-indices-name' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clear-empties-indexes');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `clear contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('add then remove returns undefined from getDirectRelation', async () => {
|
||||
async function check(r) {
|
||||
const gi = new GraphIndices();
|
||||
gi.addRelation(r);
|
||||
const found = gi.getDirectRelation(r.src, r.rel, r.dst);
|
||||
if (!found) throw new Error(`expected to find ${JSON.stringify(r)}`);
|
||||
gi.removeRelation(r, r.src, r.dst, r.rel);
|
||||
const afterRemove = gi.getDirectRelation(r.src, r.rel, r.dst);
|
||||
if (afterRemove !== undefined) {
|
||||
throw new Error(`expected undefined after remove, got ${JSON.stringify(afterRemove)}`);
|
||||
}
|
||||
// Indexes should be empty for this (src,rel,dst)
|
||||
const fromSrc = gi.getRelationsFromSrc(r.src, r.rel);
|
||||
if (fromSrc.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
|
||||
throw new Error(`getRelationsFromSrc still contains removed relation`);
|
||||
}
|
||||
const fromDst = gi.getRelationsToDst(r.dst, r.rel);
|
||||
if (fromDst.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
|
||||
throw new Error(`getRelationsToDst still contains removed relation`);
|
||||
}
|
||||
const byName = gi.getRelationsByName(r.rel);
|
||||
if (byName.some(x => x.src === r.src && x.dst === r.dst && x.rel === r.rel)) {
|
||||
throw new Error(`getRelationsByName still contains removed relation`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const relGen = rigor.gen.tuple(
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.enum(RELATIONS),
|
||||
rigor.gen.int(0, 5),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
).map(([src, rel, dst, p]) => relObj(src, rel, dst, p));
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(relGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('add-remove-cycle', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'graph-indices-cycle' });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-cycle');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `add+remove cycle violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* rigor/logical-operators.test.js — js-rigor property tests for LogicalOperators.
|
||||
*
|
||||
* LogicalOperators handles union, intersection, exclusion, and defeasible logic
|
||||
* combinations via OWA fusion. Properties verified using a mock ruleEvaluator:
|
||||
*
|
||||
* - union with aggregator='max' → result.possibility = max(child possibilities)
|
||||
* - union with aggregator='mean' → result.possibility ≈ average of children
|
||||
* - intersection with aggregator='min' → result.possibility = min(children)
|
||||
* - exclusion (A AND NOT B) → high when A high, B low; low when A low, B high
|
||||
* - collectedValues are passed through from child rules
|
||||
* - meta.operation indicates which logical operation was applied
|
||||
* - result.possibility ∈ [0, 1]
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { LogicalOperators } from '../../src/authorization/rules/LogicalOperators.js';
|
||||
|
||||
/**
|
||||
* Build a mock ruleEvaluator that returns a fixed possibility for each rule.
|
||||
* Each rule is identified by mockKey; the evaluator looks up by mockKey.
|
||||
*/
|
||||
function makeMockRuleEvaluator(resultsByMockKey) {
|
||||
return {
|
||||
evaluateRule(userId, userKey, objectId, objectKey, rule) {
|
||||
if (rule && rule.mockKey) {
|
||||
return resultsByMockKey[rule.mockKey] ||
|
||||
{ possibility: 0, reliability: 1.0, meta: { ruleType: 'direct' }, collectedValues: [] };
|
||||
}
|
||||
return { possibility: 0, reliability: 1.0, meta: { ruleType: 'direct' }, collectedValues: [] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('LogicalOperators evaluation (rigor)', () => {
|
||||
it('union with aggregator=max → possibility = max(child possibilities)', async () => {
|
||||
async function check(possA, possB, possC) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: [1] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: [2] },
|
||||
c: { possibility: possC, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'c' } }, collectedValues: [3] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
const rule = { type: 'logical', union: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }, { mockKey: 'c' }], aggregator: 'max' } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
const expected = Math.max(possA, possB, possC);
|
||||
if (Math.abs(result.possibility - expected) > 0.001) {
|
||||
throw new Error(`max aggregator: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
if (result.meta?.operation !== 'union') {
|
||||
throw new Error(`meta.operation=${result.meta?.operation}, expected 'union'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('union-max', ({ 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 === 'union-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `union-max contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('intersection with aggregator=min → possibility = min(child possibilities)', async () => {
|
||||
async function check(possA, possB, possC) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: [1] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: [2] },
|
||||
c: { possibility: possC, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'c' } }, collectedValues: [3] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
const rule = { type: 'logical', intersection: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }, { mockKey: 'c' }], aggregator: 'min' } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
const expected = Math.min(possA, possB, possC);
|
||||
if (Math.abs(result.possibility - expected) > 0.001) {
|
||||
throw new Error(`min aggregator: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
if (result.meta?.operation !== 'intersection') {
|
||||
throw new Error(`meta.operation=${result.meta?.operation}, expected 'intersection'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('intersection-min', ({ 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 === 'intersection-min');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `intersection-min contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('union with aggregator=mean → possibility ≈ average of children', async () => {
|
||||
async function check(possA, possB) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: [1] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: [2] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
const rule = { type: 'logical', union: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }], aggregator: 'mean' } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
const expected = (possA + possB) / 2;
|
||||
if (Math.abs(result.possibility - expected) > 0.001) {
|
||||
throw new Error(`mean aggregator: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('union-mean', ({ 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 === 'union-mean');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `union-mean contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('exclusion (A AND NOT B) → high when A high, B low', async () => {
|
||||
async function check(possA, possB) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: [1] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: [2] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
// exclusion is its own field, not an intersection aggregator
|
||||
const rule = { type: 'logical', exclusion: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }] } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
// A AND NOT B is high when A high, B low
|
||||
if (possA > 0.8 && possB < 0.2) {
|
||||
if (result.possibility < 0.5) {
|
||||
throw new Error(`A high, B low: expected high exclusion, got ${result.possibility}`);
|
||||
}
|
||||
}
|
||||
// A AND NOT B is low when A low, B high
|
||||
if (possA < 0.2 && possB > 0.8) {
|
||||
if (result.possibility > 0.5) {
|
||||
throw new Error(`A low, B high: expected low exclusion, got ${result.possibility}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('exclusion', ({ 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 === 'exclusion');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `exclusion contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always (union, intersection, exclusion)', async () => {
|
||||
async function check(possA, possB) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: [1] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: [2] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
const rule = { type: 'logical', union: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }], aggregator: 'max' } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('collectedValues from child rules are concatenated', async () => {
|
||||
async function check(possA, possB) {
|
||||
const evaluator = makeMockRuleEvaluator({
|
||||
a: { possibility: possA, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'a' } }, collectedValues: ['val-a'] },
|
||||
b: { possibility: possB, reliability: 1.0, meta: { ruleType: 'direct', rule: { mockKey: 'b' } }, collectedValues: ['val-b'] }
|
||||
});
|
||||
const logicalOps = new LogicalOperators({}, evaluator);
|
||||
const rule = { type: 'logical', union: { rules: [{ mockKey: 'a' }, { mockKey: 'b' }], aggregator: 'max' } };
|
||||
const result = logicalOps._evaluateRule('u', 'u', 'o', 'o', rule, new Set(), null, { collectValues: true });
|
||||
if (!Array.isArray(result.collectedValues)) {
|
||||
throw new Error(`collectedValues not an array: ${JSON.stringify(result.collectedValues)}`);
|
||||
}
|
||||
// Both should be present
|
||||
if (!result.collectedValues.includes('val-a') || !result.collectedValues.includes('val-b')) {
|
||||
throw new Error(`collectedValues missing child values: ${JSON.stringify(result.collectedValues)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.5, max: 1 }),
|
||||
rigor.gen.float({ min: 0.5, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('collected-values-concat', ({ 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 === 'collected-values-concat');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `collected-values-concat violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* rigor/manager-index-parity.test.js — js-rigor property tests for the
|
||||
* RelationManager lookup layer vs the GraphIndices ground truth.
|
||||
*
|
||||
* RelationManager.getRelationsFromSrc/ToDst consult the RF-08 lookup
|
||||
* caches (relationLookupCache/valueLookupCache); GraphIndices holds the
|
||||
* ground truth. The two must agree after every mutation — a divergence
|
||||
* means a lookup cache went stale.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - LOOKUP PARITY: after every add/remove/overwrite, both layers return
|
||||
* identical (src, dst, possibility) sets for every node/relation pair.
|
||||
* - COUNT CONSISTENCY: relations.length equals the number of distinct
|
||||
* tuples across all index lookups.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
|
||||
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
const RELS = ['r1', 'r2'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function sig(rels) {
|
||||
return rels.map(r => [r.src, r.dst, r.possibility]).sort((x, y) => x[0] - y[0] || x[1] - y[1]).map(x => x.join('|')).join(';');
|
||||
}
|
||||
|
||||
function verifyAllLookups(arb, tag) {
|
||||
for (const rel of RELS) {
|
||||
for (const node of NODES) {
|
||||
const srcId = arb.resolveNodeId(node);
|
||||
if (srcId === undefined) continue;
|
||||
const managerFrom = arb.relationManager.getRelationsFromSrc(srcId, rel);
|
||||
const indexFrom = arb.indices.getRelationsFromSrc(srcId, rel);
|
||||
const s1 = sig(managerFrom);
|
||||
const s2 = sig(indexFrom);
|
||||
if (s1 !== s2) {
|
||||
fail(`${tag} fromSrc(${node}, ${rel}) mismatch: manager=[${s1}] index=[${s2}]`);
|
||||
}
|
||||
const managerTo = arb.relationManager.getRelationsToDst(srcId, rel);
|
||||
const indexTo = arb.indices.getRelationsToDst(srcId, rel);
|
||||
const t1 = sig(managerTo);
|
||||
const t2 = sig(indexTo);
|
||||
if (t1 !== t2) {
|
||||
fail(`${tag} toDst(${node}, ${rel}) mismatch: manager=[${t1}] index=[${t2}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCount(arb, edges, tag) {
|
||||
const n = arb.relations.length;
|
||||
if (n !== edges.length) {
|
||||
fail(`${tag} relations.length=${n} expected=${edges.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
for (const r of RELS) arb.setRelationConfig(r, { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('Manager vs index lookup parity (rigor)', () => {
|
||||
it('LOOKUP PARITY + COUNT CONSISTENCY through random mutation sequences', async () => {
|
||||
async function check({ seed, mutations }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = [];
|
||||
const arb = buildArbiter();
|
||||
|
||||
// Initial random edges
|
||||
for (const rel of RELS) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < 0.5) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
verifyAllLookups(arb, 'initial');
|
||||
verifyCount(arb, edges, 'initial');
|
||||
|
||||
for (let i = 0; i < mutations; i++) {
|
||||
const rel = RELS[Math.floor(rng.next() * 2)];
|
||||
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
||||
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
verifyAllLookups(arb, `mutation ${i}`);
|
||||
verifyCount(arb, edges, `mutation ${i}`);
|
||||
}
|
||||
|
||||
// Overwrite storm: same tuple 5 times, then lookups must show the last value once
|
||||
const [src, dst] = ['user:alice', 'mid:1'];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, 'r1', dst, { possibility: p });
|
||||
}
|
||||
const uid = arb.resolveNodeId(src);
|
||||
const fromManager = arb.relationManager.getRelationsFromSrc(uid, 'r1');
|
||||
const fromIndex = arb.indices.getRelationsFromSrc(uid, 'r1');
|
||||
const count = fromIndex.filter(r => r.dst === arb.resolveNodeId(dst)).length;
|
||||
if (count !== 1) {
|
||||
fail(`overwrite storm left ${count} tuples in index`);
|
||||
}
|
||||
if (sig(fromManager) !== sig(fromIndex)) {
|
||||
fail(`overwrite storm desynced manager vs index`);
|
||||
}
|
||||
return { edges: edges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
mutations: rigor.gen.int(3, 10)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('lookup-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'manager-index-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'lookup-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `lookup parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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) {
|
||||
this.tuples.set(this.key(src, rel, dst), p);
|
||||
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;
|
||||
if (rel === 'can_read') {
|
||||
for (const [k, p] of this.tuples) {
|
||||
if (k === this.key(src, 'owner', DOC)) possibility = Math.max(possibility, p);
|
||||
}
|
||||
} 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.
|
||||
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) {
|
||||
possibility = Math.max(possibility, Math.min(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { possibility: Math.round(possibility * 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) {
|
||||
arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p });
|
||||
ops.push(['add', src, rel, dst, p]);
|
||||
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 };
|
||||
},
|
||||
clone() {
|
||||
const fresh = makeSut();
|
||||
for (const op of ops) {
|
||||
if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4]);
|
||||
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)
|
||||
);
|
||||
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) => model.add(src, rel, dst, p)
|
||||
},
|
||||
{
|
||||
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(' → ')}\n` +
|
||||
` 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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* rigor/multi-hop-rule.test.js — js-rigor property tests for MultiHopRule.
|
||||
*
|
||||
* MultiHopRule performs BFS path-finding through a graph. Properties verified:
|
||||
*
|
||||
* - Missing relation → possibility=0, reason='no_relation_specified'
|
||||
* - Empty graph (no relations of the target type) → possibility=0
|
||||
* - Single-hop path with strength s → possibility=s (max aggregation default)
|
||||
* - Multiple paths → max fused
|
||||
* - result.possibility ∈ [0, 1]
|
||||
* - relation required in rule config
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { MultiHopRule } from '../../src/authorization/rules/MultiHopRule.js';
|
||||
|
||||
describe('MultiHopRule evaluation (rigor)', () => {
|
||||
it('missing relation in rule → possibility=0, reason=no_relation_specified', async () => {
|
||||
async function check(relName) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
const rule = new MultiHopRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
// Pass a rule with no relation field
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'multi_hop', relation: relName }, // rigor may pass empty string
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (relName === '' || relName === undefined || relName === null) {
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected possibility=0 for missing relation, got ${result.possibility}`);
|
||||
}
|
||||
if (result.reason !== 'no_relation_specified') {
|
||||
throw new Error(`expected reason='no_relation_specified', got '${result.reason}'`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.string(0, 20)) // may be empty
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('missing-relation', ({ 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 === 'missing-relation');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `missing-relation contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always (sparse graph)', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new MultiHopRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('single direct relation with strength s → possibility=s (max aggregation)', async () => {
|
||||
async function check(strength) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
|
||||
const rule = new MultiHopRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// With max aggregation and a single path, possibility should equal strength
|
||||
if (Math.abs(result.possibility - strength) > 0.001) {
|
||||
throw new Error(`expected possibility=${strength}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.float({ min: 0.01, max: 1 }))
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('single-path-strength', ({ 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 === 'single-path-strength');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `single-path-strength contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('no path in graph → possibility=0', async () => {
|
||||
async function check() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
// No relations at all
|
||||
const rule = new MultiHopRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'multi_hop', relation: 'owner', maxDepth: 3 },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected possibility=0 (no path), got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-path', ({ 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 === 'no-path');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-path contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('2-hop path through intermediate node finds path', async () => {
|
||||
async function check(strength1, strength2) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('team:eng', 'team');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.addRelation('user:alice', 'member', 'team:eng', { possibility: strength1 });
|
||||
arbiter.addRelation('team:eng', 'owner', 'doc:secret', { possibility: strength2 });
|
||||
const rule = new MultiHopRule(arbiter);
|
||||
const userId = arbiter.resolveNodeId('user:alice');
|
||||
const objectId = arbiter.resolveNodeId('doc:secret');
|
||||
// This requires different relations in the chain, but MultiHopRule uses
|
||||
// single relation 'member' — so it can only follow that one relation type.
|
||||
// Try with same relation 'member' instead, where team is also a doc
|
||||
arbiter.addRelation('user:alice', 'member', 'doc:secret', { possibility: 0.5 }); // direct fallback
|
||||
const result = rule._evaluateRule(
|
||||
userId, 'user:alice', objectId, 'doc:secret',
|
||||
{ type: 'multi_hop', relation: 'member', maxDepth: 3 },
|
||||
new Set(),
|
||||
null,
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// With max aggregation, possibility should be at least max(0.5, anything-from-strength1)
|
||||
if (result.possibility <= 0) {
|
||||
throw new Error(`expected positive possibility with path, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-hop-finds-path', ({ 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 === 'multi-hop-finds-path');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `multi-hop-finds-path violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* rigor/multi-object-independence.test.js — js-rigor property tests for
|
||||
* cross-object isolation.
|
||||
*
|
||||
* Shared groups connect multiple objects: alice is a member of group:eng,
|
||||
* and BOTH doc:1 and doc:2 have owner tuples pointing at group:eng.
|
||||
* Mutations affecting one object must never change another object's
|
||||
* checks.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - PER-OBJECT ORACLE PARITY: every check on every object equals the
|
||||
* per-object oracle computed from the edge set (TTU:
|
||||
* max over tuples of min(tupleP, memberP); chain: BFS per object).
|
||||
* - MUTATION ISOLATION: after every mutation targeting one object, all
|
||||
* OTHER objects' checks are unchanged (equal to their own oracles).
|
||||
*/
|
||||
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];
|
||||
const USERS = ['user:alice', 'user:bob'];
|
||||
const DOCS = ['doc:1', 'doc:2', 'doc:3'];
|
||||
const GROUPS = ['group:eng', 'group:design'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of [...USERS, ...DOCS, ...GROUPS]) {
|
||||
arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('doc') ? 'doc' : 'group');
|
||||
}
|
||||
arb.setRelationConfig('owner', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
arb.setRelationConfig('can_edit', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
arb.setRelationConfig('viewer', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of2', { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
function randomState(rng) {
|
||||
// Random membership + tuple edges
|
||||
const edges = [];
|
||||
for (const user of USERS) {
|
||||
for (const grp of GROUPS) {
|
||||
if (rng.next() < 0.6) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
edges.push(['member_of', user, grp, p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const doc of DOCS) {
|
||||
for (const grp of GROUPS) {
|
||||
if (rng.next() < 0.6) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
edges.push(['owner', doc, grp, p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges) {
|
||||
for (const [rel, src, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
}
|
||||
|
||||
function ttuOracle(user, doc, edges) {
|
||||
let best = 0;
|
||||
for (const [rel, src, dst, p] of edges) {
|
||||
if (rel !== 'owner' || src !== doc) continue;
|
||||
const member = edges.find(e => e[0] === 'member_of' && e[1] === user && e[2] === dst);
|
||||
best = Math.max(best, Math.min(p, member ? member[3] : 0));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function checkAll(arb, edges) {
|
||||
const results = {};
|
||||
for (const user of USERS) {
|
||||
for (const doc of DOCS) {
|
||||
results[`${user}|${doc}`] = {
|
||||
got: arb.check(user, 'can_edit', doc, {}).possibility,
|
||||
oracle: ttuOracle(user, doc, edges)
|
||||
};
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('Multi-object independence (rigor)', () => {
|
||||
it('PER-OBJECT ORACLE PARITY + MUTATION ISOLATION through random mutations', async () => {
|
||||
async function check({ seed, mutations }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomState(rng);
|
||||
const arb = buildArbiter();
|
||||
applyEdges(arb, edges);
|
||||
|
||||
const verify = (tag) => {
|
||||
const results = checkAll(arb, edges);
|
||||
for (const [key, r] of Object.entries(results)) {
|
||||
if (Math.abs(r.got - r.oracle) > EPS) {
|
||||
fail(`${tag} ${key}: got=${r.got} oracle=${r.oracle}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
verify('initial');
|
||||
|
||||
for (let i = 0; i < mutations; i++) {
|
||||
// Mutate a single edge; the target is one user/doc pair
|
||||
const rel = rng.next() < 0.5 ? 'owner' : 'member_of';
|
||||
const src = rel === 'owner' ? DOCS[Math.floor(rng.next() * DOCS.length)] : USERS[Math.floor(rng.next() * USERS.length)];
|
||||
const dst = GROUPS[Math.floor(rng.next() * GROUPS.length)];
|
||||
const idx = edges.findIndex(e => e[0] === rel && e[1] === src && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([rel, src, dst, p]);
|
||||
}
|
||||
verify(`mutation ${i}`);
|
||||
}
|
||||
return { edges: edges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 80000),
|
||||
mutations: rigor.gen.int(3, 10)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-object-isolation', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000, seed: 'multi-object-independence' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'multi-object-isolation');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `multi-object isolation violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* rigor/node-lifecycle.test.js — js-rigor property tests for node removal
|
||||
* and re-addition semantics.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - REMOVE CASCADE: removeNode(key) deletes every relation incident to
|
||||
* the node (from the relations array AND the indices), and checks
|
||||
* reflect the pruned graph exactly — chain results match a BFS oracle
|
||||
* on the pruned edge set, and checks that never touched the removed
|
||||
* node are unchanged.
|
||||
* - MISSING USER: removing the user node makes its checks report
|
||||
* missing_node semantics (0).
|
||||
* - IDEMPOTENCE: removing a non-existent node returns false and leaves
|
||||
* the state untouched.
|
||||
* - RE-ADD: re-adding the key yields a fresh node with no stale
|
||||
* relations; new edges take effect; old ids do not leak.
|
||||
*/
|
||||
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];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function randomEdges(rng) {
|
||||
const edges = [];
|
||||
for (const rel of ['r1', 'r2']) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < 0.5) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function edgeKey(src, rel, dst) {
|
||||
return `${src}|${rel}|${dst}`;
|
||||
}
|
||||
|
||||
function chainOracle(steps, edges) {
|
||||
const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]]));
|
||||
let frontier = new Map([['user:alice', 1.0]]);
|
||||
for (const step of steps) {
|
||||
const { relation, direction } = step;
|
||||
const next = new Map();
|
||||
for (const [node, p] of frontier) {
|
||||
for (const [key, ep] of em) {
|
||||
const [s, r, d] = key.split('|');
|
||||
if (r !== relation) continue;
|
||||
const matches = direction === 'out' ? s === node : d === node;
|
||||
if (!matches) continue;
|
||||
const nxt = direction === 'out' ? d : s;
|
||||
if (nxt === node) continue;
|
||||
const np = Math.min(p, ep);
|
||||
const cur = next.get(nxt);
|
||||
if (cur === undefined || np > cur) next.set(nxt, np);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) break;
|
||||
}
|
||||
return frontier.get('doc:1') || 0;
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('Node lifecycle semantics (rigor)', () => {
|
||||
it('REMOVE CASCADE: removing a node prunes its edges exactly; checks match the pruned graph', async () => {
|
||||
async function check({ seed, removeTarget }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
|
||||
const baseline = arb.check('user:alice', 'target', 'doc:1', {}).possibility;
|
||||
const baselineOracle = chainOracle(
|
||||
[{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
|
||||
edges
|
||||
);
|
||||
if (Math.abs(baseline - baselineOracle) > EPS) {
|
||||
fail(`baseline mismatch: ${baseline} vs ${baselineOracle}`);
|
||||
}
|
||||
|
||||
// Sanity: every edge lands in the index
|
||||
for (const [src, rel, dst] of edges) {
|
||||
const srcId = arb.resolveNodeId(src);
|
||||
const dstId = arb.resolveNodeId(dst);
|
||||
const found = arb.indices.getDirectRelation(srcId, rel, dstId);
|
||||
if (!found) {
|
||||
fail(`edge ${src} ${rel} ${dst} missing from index before removal`);
|
||||
}
|
||||
}
|
||||
|
||||
const prunedEdges = edges.filter(e => e[0] !== removeTarget && e[2] !== removeTarget);
|
||||
|
||||
// Remove twice: first cascade, then idempotent no-op
|
||||
const first = arb.nodeManager.removeNode(removeTarget);
|
||||
if (!first) fail(`removeNode(${removeTarget}) returned false on existing node`);
|
||||
|
||||
// 1. No incident edges remain in the relations array
|
||||
const nodeId = null; // id was deleted; scan by key instead
|
||||
const incidentLeft = arb.relations.some(r => {
|
||||
const srcKey = arb.keyByNodeId.get(r.src);
|
||||
const dstKey = arb.keyByNodeId.get(r.dst);
|
||||
return srcKey === removeTarget || dstKey === removeTarget;
|
||||
});
|
||||
if (incidentLeft) {
|
||||
fail(`relations still reference removed node ${removeTarget}`);
|
||||
}
|
||||
|
||||
// 2. Chain check matches the pruned-graph oracle
|
||||
const expectedP = chainOracle(
|
||||
[{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
|
||||
prunedEdges
|
||||
);
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (removeTarget === 'user:alice') {
|
||||
if (res.reason !== 'missing_node' && res.possibility !== 0) {
|
||||
fail(`removed user check: expected missing_node, got ${JSON.stringify(res)}`);
|
||||
}
|
||||
} else if (Math.abs(res.possibility - expectedP) > EPS) {
|
||||
fail(`post-removal mismatch: oracle=${expectedP} got=${res.possibility} removed=${removeTarget} edges=${JSON.stringify(prunedEdges)}`);
|
||||
}
|
||||
|
||||
// 3. Direct checks on remaining edges still work
|
||||
for (const [src, rel, dst, p] of prunedEdges.slice(0, 3)) {
|
||||
const srcId = arb.resolveNodeId(src);
|
||||
const dstId = arb.resolveNodeId(dst);
|
||||
if (srcId === undefined || dstId === undefined) continue;
|
||||
const found = arb.indices.getDirectRelation(srcId, rel, dstId);
|
||||
if (!found || Math.abs(found.possibility - p) > EPS) {
|
||||
fail(`surviving edge ${src} ${rel} ${dst} lost (${JSON.stringify(found)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Idempotence
|
||||
const second = arb.nodeManager.removeNode(removeTarget);
|
||||
if (second !== false) fail(`second removeNode returned ${second}, expected false`);
|
||||
const afterSecond = arb.relations.length;
|
||||
if (afterSecond !== prunedEdges.length) {
|
||||
fail(`idempotent remove changed state: ${afterSecond} relations, expected ${prunedEdges.length}`);
|
||||
}
|
||||
|
||||
return { pruned: prunedEdges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 80000),
|
||||
removeTarget: rigor.gen.oneOf(NODES)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('remove-cascade', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000, seed: 'node-lifecycle-remove' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-cascade');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `remove cascade violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('RE-ADD: re-adding a removed node key is a fresh node with working edges and no stale state', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
|
||||
arb.nodeManager.removeNode('mid:1');
|
||||
|
||||
// Re-add the node and a fresh edge through it
|
||||
arb.addNode('mid:1', 'mid');
|
||||
const fresh = Math.random() < 0.5 ? 0.5 : 1;
|
||||
arb.addRelation('user:alice', 'r1', 'mid:1', { possibility: fresh });
|
||||
arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });
|
||||
|
||||
const pruned = edges.filter(e => e[0] !== 'mid:1' && e[2] !== 'mid:1');
|
||||
const viaOld = chainOracle(
|
||||
[{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
|
||||
[...pruned, ['user:alice', 'r1', 'mid:1', fresh], ['mid:1', 'r2', 'doc:1', 1]]
|
||||
);
|
||||
// The path through the re-added node is min(fresh, 1) = fresh
|
||||
const expected = Math.max(
|
||||
chainOracle([{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], pruned),
|
||||
fresh
|
||||
);
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(res.possibility - expected) > EPS) {
|
||||
fail(`re-add mismatch: oracle=${expected} got=${res.possibility}`);
|
||||
}
|
||||
// Exactly one r1 edge user->mid:1
|
||||
const count = arb.relations.filter(r => r.rel === 'r1' && r.src === arb.resolveNodeId('user:alice') && r.dst === arb.resolveNodeId('mid:1')).length;
|
||||
if (count !== 1) {
|
||||
fail(`re-add left ${count} user->mid:1 r1 tuples`);
|
||||
}
|
||||
return { expected };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 60000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('node-readd', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'node-lifecycle-readd' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'node-readd');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `re-add violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* rigor/node-manager.test.js — js-rigor property tests for NodeManager.
|
||||
*
|
||||
* NodeManager owns the three index structures that map the graph:
|
||||
* - nodes: Map<nodeId, { key, type, data, ... }>
|
||||
* - nodeIdByKey: Map<key, nodeId>
|
||||
* - keyByNodeId: Map<nodeId, key>
|
||||
*
|
||||
* Properties verified:
|
||||
* - Inverse maps: getNodeKey(getNodeId(key)) === key, getNodeId(getNodeKey(id)) === id
|
||||
* - Idempotence: addNode(key, ...) twice returns the same nodeId
|
||||
* - Size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId|
|
||||
* - Monotonic nextNodeId: nextNodeId is strictly increasing across distinct addNode calls
|
||||
* - removeNode cleans all three indexes
|
||||
* - updateNodeData merges data into existing node
|
||||
* - clearNodes resets all three indexes and nextNodeId
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { NodeManager } from '../../src/core/NodeManager.js';
|
||||
|
||||
const NODE_TYPES = ['user', 'document', 'group', 'role', 'project'];
|
||||
|
||||
/**
|
||||
* Build a minimal arbiter stub that satisfies NodeManager's surface.
|
||||
* Records relationManager.removeRelation calls for inspection.
|
||||
*/
|
||||
function makeArbiter() {
|
||||
const arbiter = {
|
||||
nodeIdByKey: new Map(),
|
||||
keyByNodeId: new Map(),
|
||||
nodes: new Map(),
|
||||
nextNodeId: 0,
|
||||
relations: [],
|
||||
removedRelations: [],
|
||||
embeddingManager: null,
|
||||
similarityManager: null,
|
||||
dependencyIndex: null,
|
||||
decisionCache: null,
|
||||
relationManager: {
|
||||
removeRelation(srcKey, rel, dstKey) {
|
||||
arbiter.removedRelations.push({ srcKey, rel, dstKey });
|
||||
// Cascade: drop the matching entries from arbiter.relations
|
||||
for (let i = arbiter.relations.length - 1; i >= 0; i--) {
|
||||
const r = arbiter.relations[i];
|
||||
const srcId = arbiter.nodeIdByKey.get(srcKey);
|
||||
const dstId = arbiter.nodeIdByKey.get(dstKey);
|
||||
if (r.src === srcId && r.dst === dstId && r.rel === rel) {
|
||||
arbiter.relations.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
const arbiter = makeArbiter();
|
||||
const manager = new NodeManager(arbiter);
|
||||
return { manager, arbiter };
|
||||
}
|
||||
|
||||
describe('NodeManager index invariants (rigor)', () => {
|
||||
it('inverse maps: getNodeKey(getNodeId(key)) === key and back', async () => {
|
||||
async function check(key, type) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
manager.addNode(key, type);
|
||||
const nodeId = arbiter.nodeIdByKey.get(key);
|
||||
if (nodeId === undefined) throw new Error(`addNode failed for key=${key}`);
|
||||
|
||||
const backKey = manager.getNodeKey(nodeId);
|
||||
if (backKey !== key) {
|
||||
throw new Error(`round-trip mismatch: ${key} → ${nodeId} → ${backKey}`);
|
||||
}
|
||||
const backId = manager.getNodeId(key);
|
||||
if (backId !== nodeId) {
|
||||
throw new Error(`getNodeId(${key}) = ${backId}, expected ${nodeId}`);
|
||||
}
|
||||
return { nodeId, key };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(NODE_TYPES)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('inverse-maps', ({ 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 === 'inverse-maps');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`inverse-map property violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('addNode is idempotent for the same key', async () => {
|
||||
async function check(key, type) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
const id1 = manager.addNode(key, type);
|
||||
const id2 = manager.addNode(key, type); // duplicate
|
||||
if (id1 !== id2) {
|
||||
throw new Error(`addNode not idempotent: ${id1} vs ${id2} for key=${key}`);
|
||||
}
|
||||
// Only one entry in nodes
|
||||
if (manager.getNodeCount() !== 1) {
|
||||
throw new Error(`expected 1 node, got ${manager.getNodeCount()}`);
|
||||
}
|
||||
// nextNodeId should NOT have advanced for the duplicate
|
||||
if (arbiter.nextNodeId !== 1) {
|
||||
throw new Error(`expected nextNodeId=1 after idempotent add, got ${arbiter.nextNodeId}`);
|
||||
}
|
||||
return id1;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.enum(NODE_TYPES)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('addNode-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 === 'addNode-idempotent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`addNode idempotence violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId| after every mutation', async () => {
|
||||
async function check(operations) {
|
||||
// Each operation is a tuple [op, ...args]. Op codes:
|
||||
// 0: addNode(key, type)
|
||||
// 1: removeNode(key)
|
||||
// 2: clearNodes()
|
||||
const { manager } = makeManager();
|
||||
for (const op of operations) {
|
||||
if (op[0] === 0) manager.addNode(op[1], op[2]);
|
||||
else if (op[0] === 1) manager.removeNode(op[1]);
|
||||
else if (op[0] === 2) manager.clearNodes();
|
||||
|
||||
const n1 = manager.getAllNodes().length;
|
||||
const n2 = manager.getAllNodeKeys().length;
|
||||
const n3 = manager.arbiter.keyByNodeId.size;
|
||||
if (n1 !== n2 || n2 !== n3) {
|
||||
throw new Error(
|
||||
`size mismatch after op=${JSON.stringify(op)}: ` +
|
||||
`nodes=${n1} nodeIdByKey=${n2} keyByNodeId=${n3}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build a generator that produces a sequence of operations.
|
||||
const opGen = rigor.gen.array(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.string(1, 10), rigor.gen.enum(NODE_TYPES)),
|
||||
rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.string(1, 10)),
|
||||
rigor.gen.tuple(rigor.gen.constant(2))
|
||||
]),
|
||||
1, 8
|
||||
);
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(opGen)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('size-invariant', ({ 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 === 'size-invariant');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`size invariant violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('nextNodeId advances monotonically across distinct addNode calls', async () => {
|
||||
async function check(keys, types) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
if (keys.length !== types.length) return; // skip ill-formed
|
||||
const ids = [];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
ids.push(manager.addNode(keys[i], types[i]));
|
||||
}
|
||||
const uniqueKeys = new Set(keys);
|
||||
// nextNodeId should equal uniqueKeys.size after all adds
|
||||
// (idempotence ensures duplicates don't bump nextNodeId)
|
||||
if (arbiter.nextNodeId !== uniqueKeys.size) {
|
||||
throw new Error(
|
||||
`expected nextNodeId=${uniqueKeys.size}, got ${arbiter.nextNodeId}`
|
||||
);
|
||||
}
|
||||
// IDs returned should be unique across unique keys
|
||||
const uniqueIds = new Set(ids);
|
||||
if (uniqueIds.size !== uniqueKeys.size) {
|
||||
throw new Error(
|
||||
`expected ${uniqueKeys.size} unique IDs, got ${uniqueIds.size}`
|
||||
);
|
||||
}
|
||||
// Each unique key should map to a non-decreasing ID
|
||||
const seen = new Map();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const id = arbiter.nodeIdByKey.get(keys[i]);
|
||||
if (seen.has(keys[i])) {
|
||||
if (seen.get(keys[i]) !== id) {
|
||||
throw new Error(`key ${keys[i]} mapped to different IDs`);
|
||||
}
|
||||
} else {
|
||||
seen.set(keys[i], id);
|
||||
// ID must equal current nextNodeId - 1 at time of first insertion
|
||||
if (id !== seen.size - 1) {
|
||||
throw new Error(`unexpected id ${id} for new key ${keys[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.array(rigor.gen.string(1, 8), 1, 5),
|
||||
rigor.gen.array(rigor.gen.enum(NODE_TYPES), 1, 5)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('monotonic-ids', ({ 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 === 'monotonic-ids');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`monotonic id assignment violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('removeNode removes from all three indexes', async () => {
|
||||
async function check(addKeys, removeKey) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
for (const k of addKeys) manager.addNode(k, 'user');
|
||||
if (!arbiter.nodeIdByKey.has(removeKey)) {
|
||||
// removeKey not in our adds; skip
|
||||
return true;
|
||||
}
|
||||
const removedId = arbiter.nodeIdByKey.get(removeKey);
|
||||
const result = manager.removeNode(removeKey);
|
||||
if (result !== true) {
|
||||
throw new Error(`removeNode returned ${result}, expected true`);
|
||||
}
|
||||
if (manager.getNode(removedId) !== undefined) {
|
||||
throw new Error(`nodes still has entry for ${removeKey}`);
|
||||
}
|
||||
if (arbiter.nodeIdByKey.has(removeKey)) {
|
||||
throw new Error(`nodeIdByKey still has ${removeKey}`);
|
||||
}
|
||||
if (arbiter.keyByNodeId.has(removedId)) {
|
||||
throw new Error(`keyByNodeId still has id ${removedId}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.array(rigor.gen.string(1, 8), 1, 5),
|
||||
rigor.gen.string(1, 8)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('removeNode-cleanup', ({ 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 === 'removeNode-cleanup');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`removeNode cleanup violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('clearNodes resets all state', async () => {
|
||||
async function check(addKeys) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
for (const k of addKeys) manager.addNode(k, 'user');
|
||||
manager.clearNodes();
|
||||
if (manager.getNodeCount() !== 0) {
|
||||
throw new Error(`getNodeCount=${manager.getNodeCount()} after clear, expected 0`);
|
||||
}
|
||||
if (manager.getAllNodeKeys().length !== 0) {
|
||||
throw new Error(`getAllNodeKeys non-empty after clear`);
|
||||
}
|
||||
if (arbiter.keyByNodeId.size !== 0) {
|
||||
throw new Error(`keyByNodeId non-empty after clear`);
|
||||
}
|
||||
if (arbiter.nextNodeId !== 0) {
|
||||
throw new Error(`nextNodeId=${arbiter.nextNodeId} after clear, expected 0`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.array(rigor.gen.string(1, 8), 1, 5)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('clearNodes-resets', ({ 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 === 'clearNodes-resets');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`clearNodes reset violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('updateNodeData merges into existing node', async () => {
|
||||
async function check(initialData, updateData) {
|
||||
const { manager, arbiter } = makeManager();
|
||||
manager.addNode('user:1', 'user', initialData);
|
||||
const ok = manager.updateNodeData('user:1', updateData);
|
||||
if (!ok) throw new Error(`updateNodeData returned false`);
|
||||
const node = arbiter.nodes.get(arbiter.nodeIdByKey.get('user:1'));
|
||||
for (const [k, v] of Object.entries(updateData)) {
|
||||
if (node.data[k] !== v) {
|
||||
throw new Error(`data.${k} = ${node.data[k]}, expected ${v}`);
|
||||
}
|
||||
}
|
||||
// initialData fields not in updateData should still be present
|
||||
for (const k of Object.keys(initialData)) {
|
||||
if (!(k in updateData)) {
|
||||
if (node.data[k] !== initialData[k]) {
|
||||
throw new Error(`data.${k} was clobbered: ${node.data[k]} vs initial ${initialData[k]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// updateNodeData should mark the node stale
|
||||
if (!node.stale) throw new Error('node should be stale after updateNodeData');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use small object shapes that rigor can generate
|
||||
const initialGen = rigor.gen.object({
|
||||
role: rigor.gen.enum(['admin', 'user', 'guest']),
|
||||
age: rigor.gen.int(0, 100)
|
||||
});
|
||||
const updateGen = rigor.gen.object({
|
||||
role: rigor.gen.enum(['admin', 'user', 'guest']), // can override
|
||||
email: rigor.gen.string(1, 30) // adds new key
|
||||
});
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(initialGen, updateGen)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('updateNodeData-merges', ({ 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 === 'updateNodeData-merges');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`updateNodeData merge violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* rigor/overlay-precedence.test.js — js-rigor property tests for partial
|
||||
* graph overlay semantics.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - PERSISTENT OVER PARTIAL: when both a persistent fact and a partial
|
||||
* graph fact describe the same triple, the persistent fact wins by
|
||||
* trust precedence — the check reflects the persistent possibility
|
||||
* (even when it is 0).
|
||||
* - SURFACING: removing the persistent fact lets the partial fact
|
||||
* surface; the check then reflects the partial possibility.
|
||||
* - RE-ESTABLISHMENT: re-adding the persistent fact re-asserts its
|
||||
* precedence immediately (no stale partial-only state).
|
||||
* - LAYER PRECEDENCE: two partial facts for the same triple at
|
||||
* different layers resolve to the higher-trust layer.
|
||||
*/
|
||||
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 buildArbiter() {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:1', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||||
return arbiter;
|
||||
}
|
||||
|
||||
function partialGraphWith(relation, possibility, layer = null) {
|
||||
const fact = {
|
||||
src: 'user:1',
|
||||
relation,
|
||||
dst: 'doc:1',
|
||||
possibility
|
||||
};
|
||||
if (layer) fact.layer_name = layer;
|
||||
return { relations: [fact] };
|
||||
}
|
||||
|
||||
describe('Partial graph overlay precedence (rigor)', () => {
|
||||
it('PERSISTENT OVER PARTIAL: persistent facts win by trust precedence; partial surfaces on removal', async () => {
|
||||
async function check({ pPersistent, pPartial }) {
|
||||
const arbiter = buildArbiter();
|
||||
arbiter.addRelation('user:1', 'can_read', 'doc:1', { possibility: pPersistent });
|
||||
|
||||
const partialGraph = partialGraphWith('can_read', pPartial);
|
||||
|
||||
// Persistent present: persistent wins regardless of partial strength
|
||||
const withBoth = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
if (Math.abs(withBoth.possibility - pPersistent) > EPS) {
|
||||
fail(`persistent+partial: expected persistent ${pPersistent}, got ${withBoth.possibility}`);
|
||||
}
|
||||
|
||||
// Remove persistent: partial surfaces
|
||||
arbiter.removeRelation('user:1', 'can_read', 'doc:1');
|
||||
const partialOnly = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
if (Math.abs(partialOnly.possibility - pPartial) > EPS) {
|
||||
fail(`partial-only: expected ${pPartial}, got ${partialOnly.possibility}`);
|
||||
}
|
||||
|
||||
// Re-add persistent: precedence re-asserts immediately
|
||||
arbiter.addRelation('user:1', 'can_read', 'doc:1', { possibility: pPersistent });
|
||||
const reasserted = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
if (Math.abs(reasserted.possibility - pPersistent) > EPS) {
|
||||
fail(`re-asserted: expected ${pPersistent}, got ${reasserted.possibility}`);
|
||||
}
|
||||
return { withBoth, partialOnly, reasserted };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pPersistent: rigor.gen.oneOf(POS),
|
||||
pPartial: rigor.gen.oneOf(POS)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('persistent-precedence', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'overlay-persistent-precedence' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'persistent-precedence');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `PERSISTENT OVER PARTIAL violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('LAYER PRECEDENCE: higher-trust layer wins between partial facts', async () => {
|
||||
async function check({ pHigh, pLow }) {
|
||||
const arbiter = buildArbiter();
|
||||
// Two partial facts, same triple, different layers:
|
||||
// token_projection (trust 70) > request_observed (trust 50)
|
||||
const partialGraph = {
|
||||
relations: [
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'can_read',
|
||||
dst: 'doc:1',
|
||||
possibility: pHigh,
|
||||
layer_name: 'token_projection'
|
||||
},
|
||||
{
|
||||
src: 'user:1',
|
||||
relation: 'can_read',
|
||||
dst: 'doc:1',
|
||||
possibility: pLow,
|
||||
layer_name: 'request_observed'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const result = arbiter.check('user:1', 'can_read', 'doc:1', { partialGraph });
|
||||
if (Math.abs(result.possibility - pHigh) > EPS) {
|
||||
fail(`layer precedence: expected high-trust ${pHigh}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pHigh: rigor.gen.oneOf(POS),
|
||||
pLow: rigor.gen.oneOf(POS)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('layer-precedence', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'overlay-layer-precedence' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'layer-precedence');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `LAYER PRECEDENCE violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* rigor/parent-rule.test.js — js-rigor property tests for ParentRule.
|
||||
*
|
||||
* ParentRule grants access via parent-child relationships. The user is checked
|
||||
* against the target relation on each parent of the object, then OWA-fused.
|
||||
* Properties verified:
|
||||
*
|
||||
* - No parents → possibility=0, reason='no_parent_relationship_path_above_threshold'
|
||||
* - One parent with direct access at strength s → possibility=s (or 0 if below threshold)
|
||||
* - Multiple parents → fused via OWA aggregator (default 'max')
|
||||
* - Cycle (parentKey === userKey) → possibility=0, reason='cycle'
|
||||
* - parentRelation defaults to 'parent' if not specified
|
||||
* - reverse=true flips parent lookup direction
|
||||
* - threshold cutoff: possibilities below minPossibility are dropped pre-fusion
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { ParentRule } from '../../src/authorization/rules/ParentRule.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||||
|
||||
/**
|
||||
* Build an arbiter stub whose relationManager.getRelationsFromSrc /
|
||||
* getRelationsToDst return relations from a static table, and
|
||||
* arbiter.indices.getDirectRelation looks up direct edges.
|
||||
* parents: Map<objectId, Array<{src, rel, dst, possibility}>>
|
||||
* directEdges: Map<"src|rel|dst", {possibility}>
|
||||
* keyMap: Map<nodeId, key>
|
||||
*/
|
||||
function makeArbiter({ parents, directEdges, keyMap }) {
|
||||
return {
|
||||
relationManager: {
|
||||
getRelationsFromSrc(srcId, relName) {
|
||||
if (relName !== 'parent') return [];
|
||||
// For 'parent', parents[srcId] lists relationships from src
|
||||
return parents.get(srcId) ?? [];
|
||||
},
|
||||
getRelationsToDst(dstId, relName) {
|
||||
if (relName !== 'parent') return [];
|
||||
// For 'parent', parents of dst = relations where dst === src (parent->child)
|
||||
// Wait, actually the convention is: 'parent' relation means src is the parent
|
||||
// of dst. So "get parents of dstId" = relations where dstId === dst.
|
||||
return (parents.get(dstId) ?? []).map(r => ({ ...r, _dstRel: true }));
|
||||
}
|
||||
},
|
||||
indices: {
|
||||
getDirectRelation(srcId, rel, dstId) {
|
||||
const key = `${srcId}|${rel}|${dstId}`;
|
||||
return directEdges.get(key) ?? null;
|
||||
}
|
||||
},
|
||||
resolveKey(nodeId) {
|
||||
return keyMap.get(nodeId) ?? null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('ParentRule evaluation (rigor)', () => {
|
||||
it('no parents → possibility=0, reason=no_parent_relationship_path_above_threshold', async () => {
|
||||
async function check(userKey, objectKey) {
|
||||
const arbiter = makeArbiter({ parents: new Map(), directEdges: new Map(), keyMap: new Map() });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
0, userKey, 1, objectKey,
|
||||
{ type: 'parent' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`possibility=${result.possibility}, expected 0 (no parents)`);
|
||||
}
|
||||
if (result.reason !== 'no_parent_relationship_path_above_threshold') {
|
||||
throw new Error(`reason=${result.reason}, expected 'no_parent_relationship_path_above_threshold'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-parents', ({ 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 === 'no-parents');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-parents contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('one parent with direct access at strength s → possibility=s (with threshold 0)', async () => {
|
||||
async function check(parentObjectId, strength) {
|
||||
// The object (id=99) has parent=10. userKey='u1', parentKey='p10', userId=1, parentId=10.
|
||||
// The user has direct edge to parent at the target relation with possibility=strength.
|
||||
const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
||||
const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]);
|
||||
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// Possibility should be the direct edge strength (since threshold=0)
|
||||
if (result.possibility !== strength) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${strength} (strength of direct edge)`);
|
||||
}
|
||||
if (result.reason !== 'parent_relationship_path_found') {
|
||||
throw new Error(`reason=${result.reason}, expected 'parent_relationship_path_found'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(0, 100),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('one-parent-strength', ({ 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 === 'one-parent-strength');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `one-parent-strength contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('threshold cutoff: possibilities below minPossibility drop to 0', async () => {
|
||||
async function check(strength, threshold) {
|
||||
const parents = new Map([[99, [{ src: 10, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
||||
const directEdges = new Map([[`1|owner|10`, { possibility: strength }]]);
|
||||
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{ minPossibility: threshold }
|
||||
);
|
||||
if (strength >= threshold) {
|
||||
if (result.possibility !== strength) {
|
||||
throw new Error(`strength=${strength} >= threshold=${threshold}: expected possibility=${strength}, got ${result.possibility}`);
|
||||
}
|
||||
} else {
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`strength=${strength} < threshold=${threshold}: expected possibility=0, got ${result.possibility}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.0, max: 1.0 }),
|
||||
rigor.gen.float({ min: 0.0, max: 1.0 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('threshold-cutoff', ({ 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 === 'threshold-cutoff');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `threshold-cutoff contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('cycle: parentKey === userKey → possibility=0, reason=cycle', async () => {
|
||||
async function check() {
|
||||
// Object 99 has parent = user 1. User 1 IS the parent of itself.
|
||||
const parents = new Map([[99, [{ src: 1, rel: 'parent', dst: 99, possibility: 1 }]]]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{}
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`cycle should yield possibility=0, got ${result.possibility}`);
|
||||
}
|
||||
if (result.reason !== 'cycle') {
|
||||
throw new Error(`cycle reason=${result.reason}, expected 'cycle'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-detection', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-detection');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `cycle-detection violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('multiple parents → fused via max aggregator (default)', async () => {
|
||||
async function check(strength1, strength2) {
|
||||
// Object 99 has two parents: 10 and 11. User has direct edges to both.
|
||||
const parents = new Map([[99, [
|
||||
{ src: 10, rel: 'parent', dst: 99, possibility: 1 },
|
||||
{ src: 11, rel: 'parent', dst: 99, possibility: 1 }
|
||||
]]]);
|
||||
const directEdges = new Map([
|
||||
[`1|owner|10`, { possibility: strength1 }],
|
||||
[`1|owner|11`, { possibility: strength2 }]
|
||||
]);
|
||||
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{}
|
||||
);
|
||||
const expected = Math.max(strength1, strength2);
|
||||
if (result.possibility !== expected) {
|
||||
throw new Error(`max aggregator: expected ${expected}, got ${result.possibility} (strengths ${strength1}, ${strength2})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-parent-max', ({ 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 === 'multi-parent-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `multi-parent-max contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('parentRelation defaults to "parent" when not specified in rule', async () => {
|
||||
// Two cases: rule without parentRelation → looks up 'parent'.
|
||||
// rule with parentRelation='other' → looks up 'other' (and gets nothing here).
|
||||
async function check(useOther) {
|
||||
const parents = new Map(); // no parents of either kind
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const ruleConfig = useOther
|
||||
? { type: 'parent', parentRelation: 'other', relation: 'owner' }
|
||||
: { type: 'parent', relation: 'owner' };
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
ruleConfig,
|
||||
new Set(),
|
||||
'owner',
|
||||
{}
|
||||
);
|
||||
// Both should return possibility=0 (no parents)
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args(rigor.gen.boolean()))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('parent-relation-default', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'parent-relation-default');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `parent-relation-default contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always', async () => {
|
||||
async function check(strength1, strength2) {
|
||||
const parents = new Map([[99, [
|
||||
{ src: 10, rel: 'parent', dst: 99, possibility: 1 },
|
||||
{ src: 11, rel: 'parent', dst: 99, possibility: 1 }
|
||||
]]]);
|
||||
const directEdges = new Map([
|
||||
[`1|owner|10`, { possibility: strength1 }],
|
||||
[`1|owner|11`, { possibility: strength2 }]
|
||||
]);
|
||||
const keyMap = new Map([[1, 'u1'], [10, 'p10'], [11, 'p11'], [99, 'o99']]);
|
||||
const arbiter = makeArbiter({ parents, directEdges, keyMap });
|
||||
const rule = new ParentRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'parent', parentRelation: 'parent', relation: 'owner' },
|
||||
new Set(),
|
||||
'owner',
|
||||
{}
|
||||
);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* rigor/pltc-reachability-parity.test.js — js-rigor property tests for the
|
||||
* PLTC reachability gate.
|
||||
*
|
||||
* ChainRule consults a reachability index (PLTC) when
|
||||
* enableReachabilityCheck is on: a FALSE verdict fast-fails the chain with
|
||||
* 0 ('not_reachable'); TRUE/null falls through to full evaluation. The
|
||||
* engine documents PLTC as "100% accurate", so the parity contract is:
|
||||
*
|
||||
* - ACTIVE/BYPASS PARITY: with PLTC enabled, check(user, chain, obj)
|
||||
* equals check(..., { bypassPLTC: true }) on the same graph — for
|
||||
* every graph shape and after every mutation. A divergence means the
|
||||
* reachability index disagrees with the actual edge set (stale
|
||||
* add/remove maintenance).
|
||||
*/
|
||||
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];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function randomEdges(rng) {
|
||||
const edges = [];
|
||||
for (const rel of ['r1', 'r2']) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < 0.5) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function buildArbiter(withPLTC) {
|
||||
const arb = new Arbiter(withPLTC ? { enableReachabilityCheck: true } : {});
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
|
||||
arb.setRelationConfig('target_rev', { type: 'chain', steps: [{ relation: 'r1', direction: 'in' }, { relation: 'r2', direction: 'in' }] });
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('PLTC reachability parity (rigor)', () => {
|
||||
it('ACTIVE/BYPASS PARITY: PLTC verdicts agree with ground truth through mutations', async () => {
|
||||
async function check({ seed, mutations }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter(true);
|
||||
for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
|
||||
const verify = (tag) => {
|
||||
for (const cfg of ['target', 'target_rev']) {
|
||||
const active = arb.check('user:alice', cfg, 'doc:1', {});
|
||||
const bypass = arb.check('user:alice', cfg, 'doc:1', { bypassPLTC: true });
|
||||
if (Math.abs(active.possibility - bypass.possibility) > EPS) {
|
||||
fail(`${tag} ${cfg}: PLTC active=${active.possibility} bypass=${bypass.possibility} (reason ${active.reason} vs ${bypass.reason})`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
verify('initial');
|
||||
|
||||
const rels = ['r1', 'r2'];
|
||||
for (let i = 0; i < mutations; i++) {
|
||||
const rel = rels[Math.floor(rng.next() * 2)];
|
||||
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
||||
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
verify(`mutation ${i}`);
|
||||
}
|
||||
return { mutations };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
mutations: rigor.gen.int(2, 8)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('pltc-active-bypass-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'pltc-parity-active-bypass' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'pltc-active-bypass-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `PLTC parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('PLTC FAST-FAIL SOUNDNESS: a PLTC false verdict only fires when ground truth is 0', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter(true);
|
||||
for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });
|
||||
|
||||
const active = arb.check('user:alice', 'target', 'doc:1', { includeMeta: true });
|
||||
const bypass = arb.check('user:alice', 'target', 'doc:1', { bypassPLTC: true, includeMeta: true });
|
||||
if (active.reason === 'not_reachable') {
|
||||
// Fast-failed: ground truth must be exactly 0
|
||||
if (Math.abs(bypass.possibility) > EPS) {
|
||||
fail(`fast-fail on reachable graph: active=${active.possibility} bypass=${bypass.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
} else if (Math.abs(active.possibility - bypass.possibility) > EPS) {
|
||||
fail(`non-fast-fail mismatch: active=${active.possibility} bypass=${bypass.possibility}`);
|
||||
}
|
||||
return { active: active.reason };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 100000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('pltc-fastfail-soundness', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000, seed: 'pltc-parity-fastfail' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'pltc-fastfail-soundness');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `PLTC fast-fail soundness violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* rigor/protocol-snapshot-lifecycle.test.js — snapshot lifecycle protocol
|
||||
* as a stateful rigor.object campaign with gated invariants
|
||||
* (before/after/between/when/unless).
|
||||
*
|
||||
* Lifecycle protocol (pinned from engine probes):
|
||||
* - add/remove/setConfig: allowed while LIVE.
|
||||
* - enable() (enableCondensedSnapshot): freezes RELATION writes
|
||||
* (add/remove now THROW) but queries keep working; configs stay
|
||||
* mutable; double-enable is idempotent.
|
||||
* - restore(): serializes the condensed graph and rebuilds a fresh
|
||||
* read-only arbiter; relation writes keep throwing; configs remain
|
||||
* mutable and are NOT part of the snapshot; checks reflect the
|
||||
* snapshot values (16-bit quantized).
|
||||
* - between enable() and restore(): queries serve live values exactly.
|
||||
* - after restore(): queries serve quantized snapshot values.
|
||||
* - double-restore (snapshot-of-snapshot) is supported.
|
||||
*
|
||||
* The wrapper mirrors the protocol independently (flag + tuple mirror);
|
||||
* every method runs the REAL engine and rethrows engine errors, so the
|
||||
* invariants compare engine behavior against the mirror's prediction.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
|
||||
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
|
||||
|
||||
const TOL = 0.5 / 65535 + 1e-9;
|
||||
const NODES = 6;
|
||||
const nodeKey = (id) => (id < 2 ? `u:${id}` : id === 2 ? 'g:0' : `doc:${id - 3}`);
|
||||
|
||||
function mirrorCheck(tuples, configs, src, rel, dst) {
|
||||
const srcKey = nodeKey(src);
|
||||
const dstKey = nodeKey(dst);
|
||||
const key = (s, r, d) => `${s}|${r}|${d}`;
|
||||
const p = (s, r, d) => tuples.get(key(s, r, d)) ?? 0;
|
||||
if (rel === 'can_read') {
|
||||
return p(srcKey, 'owner', dstKey);
|
||||
}
|
||||
if (rel === 'can_access') {
|
||||
let best = 0;
|
||||
for (let m = 0; m < NODES; m++) {
|
||||
best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey)));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function makeWrapper() {
|
||||
const arbiter = new Arbiter();
|
||||
for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc');
|
||||
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 tuples = new Map();
|
||||
const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`;
|
||||
const ops = [];
|
||||
|
||||
const wrapper = {
|
||||
flag: 'live',
|
||||
buffer: null,
|
||||
frozen: null,
|
||||
engine: arbiter,
|
||||
ops,
|
||||
add(src, rel, dst, p) {
|
||||
const key = nodeKey(src);
|
||||
const dstKey = nodeKey(dst);
|
||||
arbiter.addRelation(key, rel, dstKey, { possibility: p }); // throws when frozen
|
||||
tuples.set(tupleKey(key, rel, dstKey), p);
|
||||
return { ok: true };
|
||||
},
|
||||
remove(src, rel, dst) {
|
||||
arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst)); // throws when frozen
|
||||
tuples.delete(tupleKey(nodeKey(src), rel, nodeKey(dst)));
|
||||
return { ok: true };
|
||||
},
|
||||
setConfig(which) {
|
||||
if (which === 'chain') {
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
} else {
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
enable() {
|
||||
if (wrapper.flag === 'restored') {
|
||||
throw new Error('enable after restore is out of contract');
|
||||
}
|
||||
arbiter.enableCondensedSnapshot();
|
||||
wrapper.flag = 'enabled';
|
||||
return { ok: true };
|
||||
},
|
||||
restore() {
|
||||
const buf = serializeArbiterSnapshot(arbiter); // throws when not enabled
|
||||
wrapper.frozen = new Map(tuples);
|
||||
wrapper.buffer = buf;
|
||||
const next = ArbiterSnapshot.fromSnapshotBinary(buf, {}, () => new Arbiter());
|
||||
wrapper.engine = next;
|
||||
wrapper.flag = 'restored';
|
||||
return { ok: true, bytes: buf.byteLength };
|
||||
},
|
||||
check(src, rel, dst) {
|
||||
const result = wrapper.engine.check(nodeKey(src), rel, nodeKey(dst));
|
||||
const expected = mirrorCheck(tuples, null, src, rel, dst);
|
||||
return { engine: result.possibility, expected, flag: wrapper.flag };
|
||||
},
|
||||
clone() {
|
||||
const fresh = makeWrapper();
|
||||
for (const op of ops) {
|
||||
const [name, ...args] = op;
|
||||
if (name === 'check') fresh.check(...args);
|
||||
else fresh[name](...args);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
};
|
||||
|
||||
const record = (name, fn) => {
|
||||
return (...args) => {
|
||||
const result = fn(...args);
|
||||
ops.push([name, ...args]);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
wrapper.add = record('add', wrapper.add);
|
||||
wrapper.remove = record('remove', wrapper.remove);
|
||||
wrapper.setConfig = record('setConfig', wrapper.setConfig);
|
||||
wrapper.enable = record('enable', wrapper.enable);
|
||||
wrapper.restore = record('restore', wrapper.restore);
|
||||
wrapper.check = record('check', wrapper.check);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
const nodeArg = rigor.gen.int(0, NODES - 1);
|
||||
const relArg = rigor.gen.enum(['owner', 'member_of', 'reads']);
|
||||
const checkRelArg = rigor.gen.enum(['can_read', 'can_access']);
|
||||
|
||||
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(nodeArg, relArg, nodeArg, rigor.gen.oneOf([0.1, 0.3, 0.7, 0.9, 0.333]))),
|
||||
rigor.method('remove', function (src, rel, dst) { return this.remove(src, rel, dst); },
|
||||
rigor.args(nodeArg, relArg, nodeArg)),
|
||||
rigor.method('setConfig', function (which) { return this.setConfig(which); },
|
||||
rigor.args(rigor.gen.enum(['direct', 'chain']))),
|
||||
rigor.method('enable', function () { return this.enable(); }),
|
||||
rigor.method('restore', function () { return this.restore(); }),
|
||||
rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); },
|
||||
rigor.args(rigor.gen.int(0, 1), checkRelArg, rigor.gen.oneOf([3, 4, 5])))
|
||||
])],
|
||||
rigor.crucible([
|
||||
rigor.after('graph.enable', ({ objects }) => objects.graph.flag === 'enabled'),
|
||||
rigor.after('graph.restore', ({ objects }) => objects.graph.flag === 'restored' && objects.graph.frozen !== null),
|
||||
rigor.before('graph.enable', ({ objects }) => objects.graph.flag !== 'restored'),
|
||||
rigor.between('graph.enable', 'graph.restore', ({ action, objects, actual }) => {
|
||||
if (action !== 'graph.check') return true;
|
||||
return actual.engine === actual.expected && actual.flag === 'enabled';
|
||||
}),
|
||||
rigor.when(({ action, objects }) => action === 'graph.add' || action === 'graph.remove', ({ action, objects, error }) => {
|
||||
const frozen = objects.graph.flag !== 'live';
|
||||
const threw = error !== null;
|
||||
if (frozen) return threw === true;
|
||||
return threw === false;
|
||||
}),
|
||||
rigor.when(({ action }) => action === 'graph.check', ({ objects, actual }) => {
|
||||
if (actual.flag === 'restored') {
|
||||
return Math.abs(actual.engine - actual.expected) <= TOL;
|
||||
}
|
||||
return actual.engine === actual.expected;
|
||||
}),
|
||||
rigor.unless(({ action }) => action === 'graph.check', ({ action, objects, error }) => {
|
||||
if (action === 'graph.add' || action === 'graph.remove') return true;
|
||||
if (action === 'graph.restore') return objects.graph.flag !== 'live' || error !== null;
|
||||
return true;
|
||||
}),
|
||||
rigor.after('graph.setConfig', ({ objects, error }) => error === null || objects.graph.flag === 'restored' ? true : false)
|
||||
])
|
||||
).run({ effort: 400, seed: 'snapshot-lifecycle-protocol', maxTraceLength: 24 });
|
||||
|
||||
describe('Snapshot lifecycle protocol (rigor gated invariants)', () => {
|
||||
it('every random lifecycle sequence honors the protocol', () => {
|
||||
const inv = result.crucibleVerdict;
|
||||
assert.equal(inv.passed, true, [
|
||||
`protocol violated in ${inv.failureCount} cases:`,
|
||||
...result.failures.slice(0, 5).map((f) =>
|
||||
` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
|
||||
)
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* rigor/qualitative-rule-helpers.test.js — js-rigor property tests for
|
||||
* QualitativeRelationalComparatorRule's pure-function helpers.
|
||||
*
|
||||
* These helpers are pure math on qualitative scales. Properties:
|
||||
* - _getQualitativeScale: known names → specific scales; unknown → DEFAULT
|
||||
* - _calculatePeriodsElapsed: future timestamp → negative or 0; known
|
||||
* period → elapsed/periodMs; unknown period → falls back to HOUR
|
||||
* - _calculateDecayedPossibility: direction='stable' → identity;
|
||||
* direction='down' → ≤ initial; direction='up' → ≥ initial;
|
||||
* periodsElapsed=0 → identity; result ∈ scale.values
|
||||
* - _calculatePossibilityLossSteps: ≥ 0; symmetric; 0 when initial===decayed
|
||||
* - _createQualitativeInterval: lower ≤ upper; lower ≤ pointValue ≤ upper;
|
||||
* bounds clamped to scale
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { QualitativeRelationalComparatorRule } from '../../src/authorization/rules/QualitativeRelationalComparatorRule.js';
|
||||
import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../src/qualitative/QualitativeScale.js';
|
||||
|
||||
const KNOWN_SCALE_NAMES = ['binary', 'ternary', 'five-point', 'ten-point'];
|
||||
const PERIODS = ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'];
|
||||
const DIRECTIONS = ['down', 'up', 'neutral', 'stable'];
|
||||
|
||||
/**
|
||||
* Stub arbiter that satisfies BaseRule's constructor. The helpers
|
||||
* tested here don't actually call arbiter methods, but the constructor
|
||||
* stores a reference.
|
||||
*/
|
||||
function makeStubArbiter() {
|
||||
return {
|
||||
nodeIdByKey: new Map(),
|
||||
keyByNodeId: new Map(),
|
||||
relations: [],
|
||||
nodes: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
function makeRule() {
|
||||
return new QualitativeRelationalComparatorRule(makeStubArbiter(), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a scale and a value that's IN that scale.
|
||||
*/
|
||||
function scaleValuePairGen() {
|
||||
const scaleArb = rigor.gen.oneOf([
|
||||
rigor.gen.constant(QualitativeScale.binary()),
|
||||
rigor.gen.constant(QualitativeScale.ternary()),
|
||||
rigor.gen.constant(QualitativeScale.fivePoint()),
|
||||
rigor.gen.constant(QualitativeScale.tenPoint())
|
||||
]);
|
||||
// Pick a value from the chosen scale. Use oneOf-constant for the
|
||||
// index, then map to the value.
|
||||
const valueArb = rigor.gen.int(0, 9).map(i => Math.min(i, 9));
|
||||
return rigor.gen.tuple(scaleArb, valueArb).map(([scale, idx]) =>
|
||||
[scale, scale.at(Math.min(idx, scale.size - 1))]
|
||||
);
|
||||
}
|
||||
|
||||
describe('QualitativeRelationalComparatorRule._getQualitativeScale (rigor)', () => {
|
||||
it('known scale names return their corresponding scale', async () => {
|
||||
async function check(scaleName) {
|
||||
const rule = makeRule();
|
||||
const result = rule._getQualitativeScale(scaleName);
|
||||
const expectedName = scaleName;
|
||||
if (result.name !== expectedName) {
|
||||
throw new Error(
|
||||
`_getQualitativeScale(${scaleName}) returned scale named ${result.name}`
|
||||
);
|
||||
}
|
||||
// Confirm values match
|
||||
const expectedScale = {
|
||||
'binary': QualitativeScale.binary(),
|
||||
'ternary': QualitativeScale.ternary(),
|
||||
'five-point': QualitativeScale.fivePoint(),
|
||||
'ten-point': QualitativeScale.tenPoint()
|
||||
}[scaleName];
|
||||
if (result.size !== expectedScale.size) {
|
||||
throw new Error(
|
||||
`size mismatch: ${result.size} vs ${expectedScale.size}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.enum(KNOWN_SCALE_NAMES))
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('known-scales', ({ 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 === 'known-scales');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`_getQualitativeScale returned wrong scale in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('unknown scale name falls back to DEFAULT_QUALITATIVE_SCALE', async () => {
|
||||
async function check(scaleName) {
|
||||
const rule = makeRule();
|
||||
const result = rule._getQualitativeScale(scaleName);
|
||||
// The fallback uses DEFAULT_QUALITATIVE_SCALE which is fivePoint.
|
||||
if (result.name !== DEFAULT_QUALITATIVE_SCALE.name) {
|
||||
throw new Error(
|
||||
`_getQualitativeScale(${scaleName}) did not fall back. got ${result.name}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(rigor.gen.string(1, 30))
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('fallback', ({ 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 === 'fallback');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`_getQualitativeScale fallback violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigor)', () => {
|
||||
it('direction=stable is the identity function', async () => {
|
||||
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'stable', scale);
|
||||
if (result !== value) {
|
||||
throw new Error(
|
||||
`stable changed value: ${value} → ${result} (periods=${periodsElapsed}, steps=${decaySteps})`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 100),
|
||||
rigor.gen.int(0, 10)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('stable-identity', ({ 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 === 'stable-identity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`stable was not identity in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('periodsElapsed=0 yields the original value', async () => {
|
||||
async function check(scaleAndValue, direction, decaySteps) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._calculateDecayedPossibility(value, 0, decaySteps, direction, scale);
|
||||
if (result !== value) {
|
||||
throw new Error(
|
||||
`periodsElapsed=0 changed value: ${value} → ${result}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.enum(DIRECTIONS),
|
||||
rigor.gen.int(0, 10)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('zero-periods', ({ 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 === 'zero-periods');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`periodsElapsed=0 was not identity in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('direction=down never increases the scale index', async () => {
|
||||
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const initialIndex = scale.indexOf(value);
|
||||
const rule = makeRule();
|
||||
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'down', scale);
|
||||
const resultIndex = scale.indexOf(result);
|
||||
if (resultIndex > initialIndex) {
|
||||
throw new Error(
|
||||
`down went UP: ${value} (${initialIndex}) → ${result} (${resultIndex})`
|
||||
);
|
||||
}
|
||||
// Should be clamped to >= 0
|
||||
if (resultIndex < 0) {
|
||||
throw new Error(`down produced out-of-range index ${resultIndex}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 100),
|
||||
rigor.gen.int(0, 10)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('down-monotone', ({ 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 === 'down-monotone');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`direction=down violated monotonicity in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('direction=up never decreases the scale index', async () => {
|
||||
async function check(scaleAndValue, periodsElapsed, decaySteps) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const initialIndex = scale.indexOf(value);
|
||||
const rule = makeRule();
|
||||
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'up', scale);
|
||||
const resultIndex = scale.indexOf(result);
|
||||
if (resultIndex < initialIndex) {
|
||||
throw new Error(
|
||||
`up went DOWN: ${value} (${initialIndex}) → ${result} (${resultIndex})`
|
||||
);
|
||||
}
|
||||
if (resultIndex >= scale.size) {
|
||||
throw new Error(`up produced out-of-range index ${resultIndex}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 100),
|
||||
rigor.gen.int(0, 10)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('up-monotone', ({ 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 === 'up-monotone');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`direction=up violated monotonicity in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result is always a member of the scale', async () => {
|
||||
async function check(scaleAndValue, periodsElapsed, decaySteps, direction) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, direction, scale);
|
||||
if (!scale.contains(result)) {
|
||||
throw new Error(
|
||||
`result ${result} is not in scale ${scale.name}: ${scale.values}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 100),
|
||||
rigor.gen.int(0, 10),
|
||||
rigor.gen.enum(DIRECTIONS)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-in-scale', ({ 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 === 'result-in-scale');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`decayed value was not in scale in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)', () => {
|
||||
it('lower ≤ upper always', async () => {
|
||||
async function check(scaleAndValue, blurSteps, direction) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
||||
if (scale.indexOf(result.lower) > scale.indexOf(result.upper)) {
|
||||
throw new Error(
|
||||
`lower (${result.lower}) > upper (${result.upper})`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 20),
|
||||
rigor.gen.enum(DIRECTIONS)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('lower-le-upper', ({ 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 === 'lower-le-upper');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`lower > upper in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('pointValue is contained in [lower, upper]', async () => {
|
||||
async function check(scaleAndValue, blurSteps, direction) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
||||
const lowerIdx = scale.indexOf(result.lower);
|
||||
const upperIdx = scale.indexOf(result.upper);
|
||||
const valueIdx = scale.indexOf(value);
|
||||
if (valueIdx < lowerIdx || valueIdx > upperIdx) {
|
||||
throw new Error(
|
||||
`point ${value} (${valueIdx}) not in [${result.lower} (${lowerIdx}), ${result.upper} (${upperIdx})]`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 20),
|
||||
rigor.gen.enum(DIRECTIONS)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('point-contained', ({ 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 === 'point-contained');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`point not in [lower, upper] in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('bounds stay within scale', async () => {
|
||||
async function check(scaleAndValue, blurSteps, direction) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
|
||||
if (!scale.contains(result.lower)) {
|
||||
throw new Error(`lower ${result.lower} not in scale`);
|
||||
}
|
||||
if (!scale.contains(result.upper)) {
|
||||
throw new Error(`upper ${result.upper} not in scale`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.int(0, 50), // large blurSteps to exercise clamping
|
||||
rigor.gen.enum(DIRECTIONS)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('bounds-in-scale', ({ 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 === 'bounds-in-scale');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`bounds exceeded scale in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('blurSteps=0 yields degenerate interval (lower=upper=pointValue)', async () => {
|
||||
async function check(scaleAndValue, direction) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._createQualitativeInterval(value, 0, direction, scale);
|
||||
if (result.lower !== value || result.upper !== value) {
|
||||
throw new Error(
|
||||
`blurSteps=0 did not collapse to point: ${JSON.stringify(result)} for ${value}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
scaleValuePairGen(),
|
||||
rigor.gen.enum(DIRECTIONS)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('zero-blur', ({ 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 === 'zero-blur');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`blurSteps=0 was not degenerate in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QualitativeRelationalComparatorRule._calculatePossibilityLossSteps (rigor)', () => {
|
||||
it('returns 0 when initial === decayed', async () => {
|
||||
async function check(scaleAndValue) {
|
||||
const [scale, value] = scaleAndValue;
|
||||
const rule = makeRule();
|
||||
const result = rule._calculatePossibilityLossSteps(value, value, scale);
|
||||
if (result !== 0) {
|
||||
throw new Error(`expected 0, got ${result} for value=${value}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(scaleValuePairGen())
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('loss-is-zero', ({ 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 === 'loss-is-zero');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`loss was non-zero in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('is symmetric: loss(a, b) === loss(b, a)', async () => {
|
||||
async function check(scale, valueA, valueB) {
|
||||
// Pick two valid scale values
|
||||
const a = scale.at(Math.min(valueA, scale.size - 1));
|
||||
const b = scale.at(Math.min(valueB, scale.size - 1));
|
||||
const rule = makeRule();
|
||||
const ab = rule._calculatePossibilityLossSteps(a, b, scale);
|
||||
const ba = rule._calculatePossibilityLossSteps(b, a, scale);
|
||||
if (ab !== ba) {
|
||||
throw new Error(`loss not symmetric: ${ab} vs ${ba}`);
|
||||
}
|
||||
if (ab < 0) {
|
||||
throw new Error(`loss was negative: ${ab}`);
|
||||
}
|
||||
return ab;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.constant(QualitativeScale.fivePoint()),
|
||||
rigor.gen.int(0, 4),
|
||||
rigor.gen.int(0, 4)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('loss-symmetric', ({ 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 === 'loss-symmetric');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`loss not symmetric in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* rigor/relational-comparator-router.test.js — js-rigor property tests for
|
||||
* RelationalComparatorRouter._isQualitativeRule.
|
||||
*
|
||||
* The router dispatches to either the numeric or qualitative implementation
|
||||
* based on the rule config. Properties:
|
||||
* - rule.qualitative === true → qualitative (regardless of other fields)
|
||||
* - any operand scaleName → qualitative
|
||||
* - any operand decaySteps/baseBlurSteps (defined, non-null) → qualitative
|
||||
* - rule.marginSteps (defined, non-null) → qualitative
|
||||
* - otherwise → numeric
|
||||
*
|
||||
* getImplementationType is a pure pass-through to _isQualitativeRule.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { RelationalComparatorRouter } from '../../src/authorization/rules/RelationalComparatorRouter.js';
|
||||
|
||||
const SCALE_NAMES = ['five-point', 'ternary', 'ordinal-7'];
|
||||
|
||||
/**
|
||||
* Build a stub arbiter that satisfies the rule's constructor.
|
||||
* Neither _evaluateRule is called by these tests, but the constructor
|
||||
* stores references that the rule may touch on dispatch.
|
||||
*/
|
||||
function makeStubArbiter() {
|
||||
return {
|
||||
nodeIdByKey: new Map(),
|
||||
keyByNodeId: new Map(),
|
||||
relations: [],
|
||||
nodes: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
function makeRouter() {
|
||||
// _evaluateRule is what we test; pass a stub arbiter so the
|
||||
// constructor doesn't crash. We never call _evaluateRule in these
|
||||
// tests — we test _isQualitativeRule and getImplementationType
|
||||
// directly.
|
||||
return new RelationalComparatorRouter(makeStubArbiter(), {});
|
||||
}
|
||||
|
||||
describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
||||
it('rule.qualitative=true always wins', async () => {
|
||||
async function check(rule) {
|
||||
const router = makeRouter();
|
||||
const result = router._isQualitativeRule(rule);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`expected qualitative=true to win, got numeric. rule=${JSON.stringify(rule)}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
qualitative: rigor.gen.constant(true),
|
||||
// random other fields that should not matter
|
||||
left: rigor.gen.option(rigor.gen.object({
|
||||
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES))
|
||||
})),
|
||||
right: rigor.gen.option(rigor.gen.object({
|
||||
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES))
|
||||
})),
|
||||
marginSteps: rigor.gen.option(rigor.gen.int(0, 5)),
|
||||
fallbackBehavior: rigor.gen.option(rigor.gen.enum(['allow', 'deny']))
|
||||
})
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('qualitative-wins', ({ 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 === 'qualitative-wins');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`qualitative=true did not win in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('scaleName on either operand triggers qualitative', async () => {
|
||||
async function check(operand, side) {
|
||||
const router = makeRouter();
|
||||
const rule = {
|
||||
left: side === 'left' ? operand : {},
|
||||
right: side === 'right' ? operand : {}
|
||||
};
|
||||
const result = router._isQualitativeRule(rule);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`expected scaleName on ${side} to trigger qualitative, got numeric. ` +
|
||||
`operand=${JSON.stringify(operand)}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
scaleName: rigor.gen.enum(SCALE_NAMES)
|
||||
}),
|
||||
rigor.gen.enum(['left', 'right'])
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('scaleName-triggers', ({ 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 === 'scaleName-triggers');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`scaleName did not trigger qualitative in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('decaySteps/baseBlurSteps on either operand triggers qualitative', async () => {
|
||||
async function check(operand, side) {
|
||||
const router = makeRouter();
|
||||
const rule = {
|
||||
left: side === 'left' ? operand : {},
|
||||
right: side === 'right' ? operand : {}
|
||||
};
|
||||
const result = router._isQualitativeRule(rule);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(Object.keys(operand))} on ${side} to trigger qualitative`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.object({ decaySteps: rigor.gen.int(0, 10) }),
|
||||
rigor.gen.object({ baseBlurSteps: rigor.gen.int(0, 10) })
|
||||
]),
|
||||
rigor.gen.enum(['left', 'right'])
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('decay-blur-triggers', ({ 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 === 'decay-blur-triggers');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`decaySteps/baseBlurSteps did not trigger qualitative in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('marginSteps triggers qualitative (when defined and non-null)', async () => {
|
||||
async function check(marginSteps) {
|
||||
const router = makeRouter();
|
||||
const rule = { marginSteps };
|
||||
const result = router._isQualitativeRule(rule);
|
||||
if (marginSteps === undefined || marginSteps === null) {
|
||||
// marginSteps not present — should not trigger
|
||||
if (result) throw new Error(`marginSteps=${marginSteps} should not trigger qualitative`);
|
||||
} else {
|
||||
if (!result) throw new Error(`marginSteps=${marginSteps} should trigger qualitative`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.option(rigor.gen.int(0, 10))
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('marginSteps-correct', ({ 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 === 'marginSteps-correct');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`marginSteps handling was wrong in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('plain numeric rule (no qualitative flags) routes to numeric', async () => {
|
||||
async function check(leftValue, rightValue, comparator) {
|
||||
const router = makeRouter();
|
||||
const rule = {
|
||||
left: { rule: { type: 'direct' }, extractValue: true },
|
||||
right: { rule: { type: 'direct' }, extractValue: true },
|
||||
comparator,
|
||||
leftValue, // injected for testing only — production ignores
|
||||
rightValue
|
||||
};
|
||||
const result = router._isQualitativeRule(rule);
|
||||
if (result) {
|
||||
throw new Error(
|
||||
`expected numeric, got qualitative. rule=${JSON.stringify(rule)}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.int(-1000, 1000),
|
||||
rigor.gen.int(-1000, 1000),
|
||||
rigor.gen.enum(['>', '>=', '<', '<=', '==', '!='])
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('plain-numeric', ({ 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 === 'plain-numeric');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`plain numeric rule routed to qualitative in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('getImplementationType matches _isQualitativeRule', async () => {
|
||||
async function check(rule) {
|
||||
const router = makeRouter();
|
||||
const fromPrivate = router._isQualitativeRule(rule);
|
||||
const fromPublic = router.getImplementationType(rule);
|
||||
const expected = fromPrivate ? 'qualitative' : 'numeric';
|
||||
if (fromPublic !== expected) {
|
||||
throw new Error(
|
||||
`getImplementationType=${fromPublic} but _isQualitativeRule=${fromPrivate}`
|
||||
);
|
||||
}
|
||||
return fromPublic;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.object({
|
||||
qualitative: rigor.gen.option(rigor.gen.boolean()),
|
||||
left: rigor.gen.option(rigor.gen.object({
|
||||
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)),
|
||||
decaySteps: rigor.gen.option(rigor.gen.int(0, 10))
|
||||
})),
|
||||
right: rigor.gen.option(rigor.gen.object({
|
||||
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)),
|
||||
decaySteps: rigor.gen.option(rigor.gen.int(0, 10))
|
||||
})),
|
||||
marginSteps: rigor.gen.option(rigor.gen.int(0, 10))
|
||||
})
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('getImplType-consistent', ({ 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 === 'getImplType-consistent');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`getImplementationType disagreed with _isQualitativeRule in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('_hasValidQualitativeProperty: undefined/null → false, anything else → true', async () => {
|
||||
async function check(value) {
|
||||
const router = makeRouter();
|
||||
const result = router._hasValidQualitativeProperty(value);
|
||||
if (value === undefined || value === null) {
|
||||
if (result) throw new Error(`_hasValidQualitativeProperty(${value}) should be false`);
|
||||
} else {
|
||||
if (!result) throw new Error(`_hasValidQualitativeProperty(${value}) should be true`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check,
|
||||
rigor.args(
|
||||
// Exclude undefined/null from explicit inputs — the
|
||||
// generator treats them as "absent". The campaign will
|
||||
// also exercise the absent path via undefined when the
|
||||
// record is shrunk to {}.
|
||||
rigor.gen.option(
|
||||
rigor.gen.oneOf([
|
||||
rigor.gen.int(),
|
||||
rigor.gen.float(),
|
||||
rigor.gen.boolean(),
|
||||
rigor.gen.string(),
|
||||
rigor.gen.constant(0),
|
||||
rigor.gen.constant(''),
|
||||
rigor.gen.constant(false)
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('hasValidProperty', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1000 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'hasValidProperty');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true,
|
||||
`_hasValidQualitativeProperty contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* rigor/relational-comparator-rule.test.js — js-rigor property tests for RelationalComparatorRule.
|
||||
*
|
||||
* RelationalComparatorRule compares values from two operands (rules) and returns
|
||||
* access based on whether the comparison holds. Properties verified:
|
||||
*
|
||||
* - left > right (with sufficient gap) → high possibility, reason='values_compared_comparison_true'
|
||||
* - left < right → 0 possibility, reason='values_compared_comparison_false'
|
||||
* - left == right (with epsilon tolerance) → comparison true
|
||||
* - missing left operand → fallbackBehavior 'deny' yields 0
|
||||
* - missing left operand → fallbackBehavior 'allow' yields high possibility
|
||||
* - result.possibility ∈ [0, 1]
|
||||
* - minRulePossibility threshold: possibilities below it drop to 0
|
||||
* - result has stable shape (possibility, reliability, reason, meta)
|
||||
*/
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js';
|
||||
import { RelationalComparatorRule } from '../../src/authorization/rules/RelationalComparatorRule.js';
|
||||
|
||||
let arbiter, ruleEvaluator, comparatorRule;
|
||||
|
||||
beforeEach(() => {
|
||||
arbiter = new Arbiter();
|
||||
ruleEvaluator = new RuleEvaluator(arbiter);
|
||||
comparatorRule = new RelationalComparatorRule(arbiter, ruleEvaluator);
|
||||
// Set up minimal relations
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:secret', 'doc');
|
||||
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_price', { type: 'direct' });
|
||||
});
|
||||
|
||||
function evalRule(userKey, objectKey, rule, options = {}) {
|
||||
const userId = arbiter.resolveNodeId(userKey);
|
||||
const objectId = arbiter.resolveNodeId(objectKey);
|
||||
return comparatorRule._evaluateRule(userId, userKey, objectId, objectKey, rule, new Set(), null, { collectValues: true, includeMeta: true, ...options });
|
||||
}
|
||||
|
||||
describe('RelationalComparatorRule evaluation (rigor)', () => {
|
||||
it('left > right → high possibility, reason=values_compared_comparison_true', async () => {
|
||||
async function check(balance, price) {
|
||||
// Skip trivial case where balance == price
|
||||
if (balance <= price) return null;
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
|
||||
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
};
|
||||
const result = evalRule('user:alice', 'doc:secret', rule);
|
||||
if (result.reason !== 'values_compared_comparison_true') {
|
||||
throw new Error(`expected reason='values_compared_comparison_true', got '${result.reason}' (balance=${balance}, price=${price})`);
|
||||
}
|
||||
if (result.possibility <= 0.5) {
|
||||
throw new Error(`expected high possibility when balance > price, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 100, max: 10000 }),
|
||||
rigor.gen.float({ min: 0, max: 99 }) // price always less than balance
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('left-gt-right', ({ 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 === 'left-gt-right');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `left > right contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('left < right → 0 possibility, reason=values_compared_comparison_false', async () => {
|
||||
async function check(balance, price) {
|
||||
if (balance >= price) return null;
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
|
||||
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
};
|
||||
const result = evalRule('user:alice', 'doc:secret', rule);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`expected 0 when balance < price, got ${result.possibility}`);
|
||||
}
|
||||
if (result.reason !== 'values_compared_comparison_false') {
|
||||
throw new Error(`expected reason='values_compared_comparison_false', got '${result.reason}'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 99 }), // balance always less than price
|
||||
rigor.gen.float({ min: 100, max: 10000 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('left-lt-right', ({ 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 === 'left-lt-right');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `left < right contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always', async () => {
|
||||
async function check(balance, price) {
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
|
||||
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
};
|
||||
const result = evalRule('user:alice', 'doc:secret', rule);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1] (balance=${balance}, price=${price})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 10000 }),
|
||||
rigor.gen.float({ min: 0, max: 10000 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result shape is stable: possibility, reliability, reason, meta always present', async () => {
|
||||
async function check(balance, price) {
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
|
||||
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
};
|
||||
const result = evalRule('user:alice', 'doc:secret', rule);
|
||||
for (const k of ['possibility', 'reliability', 'reason', 'meta']) {
|
||||
if (!(k in result)) throw new Error(`result missing key '${k}' (full: ${JSON.stringify(result)})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 10000 }),
|
||||
rigor.gen.float({ min: 0, max: 10000 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('result-shape-stable', ({ 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 === 'result-shape-stable');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `result-shape violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('determinism: same inputs → same result (no Date.now() / randomness)', async () => {
|
||||
async function check(balance, price) {
|
||||
arbiter.addRelation('user:alice', 'has_balance', 'doc:secret', { value: balance, possibility: 1.0, changed_last_at: Date.now() });
|
||||
arbiter.addRelation('doc:secret', 'has_price', 'doc:secret', { value: price, possibility: 1.0, changed_last_at: Date.now() });
|
||||
const rule = {
|
||||
type: 'relational_comparator',
|
||||
comparator: '>',
|
||||
left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 },
|
||||
right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
|
||||
};
|
||||
const r1 = evalRule('user:alice', 'doc:secret', rule);
|
||||
const r2 = evalRule('user:alice', 'doc:secret', rule);
|
||||
if (r1.possibility !== r2.possibility) {
|
||||
throw new Error(`non-deterministic: ${r1.possibility} vs ${r2.possibility}`);
|
||||
}
|
||||
if (r1.reason !== r2.reason) {
|
||||
throw new Error(`non-deterministic reason: ${r1.reason} vs ${r2.reason}`);
|
||||
}
|
||||
return r1;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 10000 }),
|
||||
rigor.gen.float({ min: 0, max: 10000 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('determinism', ({ 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 === 'determinism');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `determinism violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* rigor-smoke.test.js — verifies the @rigor/core import path works
|
||||
* from the lib test directory and that a minimal campaign runs.
|
||||
*
|
||||
* If this test fails to import or run, none of the property tests below
|
||||
* can ship.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
|
||||
describe('js-rigor smoke', () => {
|
||||
it('exports the rigor facade', () => {
|
||||
assert.ok(rigor, 'rigor is exported');
|
||||
assert.equal(typeof rigor.campaign, 'function');
|
||||
assert.equal(typeof rigor.crucible, 'function');
|
||||
assert.ok(rigor.gen, 'rigor.gen is available');
|
||||
});
|
||||
|
||||
it('runs a minimal campaign and returns a report', async () => {
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('abs', (n) => Math.abs(n),
|
||||
rigor.args(rigor.gen.int(-100, 100)),
|
||||
rigor.metrics({ n: ({ args }) => args[0] }))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('non-negative', ({ actual }) => actual >= 0),
|
||||
rigor.invariant('idempotent', ({ actual, fn }) => fn(actual) === actual)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
assert.ok(report, 'campaign returns a report');
|
||||
assert.equal(typeof report.toTAP, 'function', 'report has toTAP()');
|
||||
// The report should have iterated at least once
|
||||
assert.ok(report.stats || report.coverage || report.summary,
|
||||
'report has stats/coverage/summary');
|
||||
});
|
||||
|
||||
it('detects a violated invariant with a minimal failing oracle', async () => {
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('alwaysZero', () => 0,
|
||||
rigor.args(rigor.gen.int()))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('equals-one', ({ actual }) => actual === 1)
|
||||
])
|
||||
).run({ effort: 50 });
|
||||
|
||||
// Report shape varies — log it for debugging.
|
||||
if (process.env.TEST_DEBUG === '1') {
|
||||
console.log('report keys:', Object.keys(report));
|
||||
console.log('report.toTAP():', report.toTAP());
|
||||
}
|
||||
// At minimum the report should have *some* representation of the failure.
|
||||
assert.ok(report, 'report returned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* rigor/snapshot-parity.test.js — js-rigor property tests for the
|
||||
* condensed-snapshot round trip.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - ROUND-TRIP PARITY: for a random graph (direct + chain configs with
|
||||
* possibilities and values), serializing via enableCondensedSnapshot +
|
||||
* serializeArbiterSnapshot and deserializing yields an arbiter whose
|
||||
* check() answers are IDENTICAL to the original's — for every user,
|
||||
* relation and object in the graph.
|
||||
* - READ-ONLY ENFORCEMENT: the deserialized snapshot rejects mutations
|
||||
* (addRelation / removeRelation / addNode throw or no-op safely) while
|
||||
* reads keep working.
|
||||
* - CONFIG PRESERVATION: relation configs (including relation overrides
|
||||
* and chains) survive the round trip and still evaluate correctly.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
|
||||
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
|
||||
|
||||
const EPS = 1e-9;
|
||||
const POS = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function factory() {
|
||||
return new Arbiter({ fastConstructionMode: true, enableInference: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a random graph; return { original, restored, checks } where checks
|
||||
* is the list of (userKey, rel, objKey) triples verified for parity.
|
||||
*/
|
||||
function buildGraph(seedCase) {
|
||||
const { users, mids, configKind } = seedCase;
|
||||
const arbiter = factory();
|
||||
const userKeys = [];
|
||||
const midKeys = [];
|
||||
for (let i = 0; i < users; i++) {
|
||||
userKeys.push(`user:${i}`);
|
||||
arbiter.addNode(`user:${i}`, 'user');
|
||||
}
|
||||
for (let i = 0; i < mids; i++) {
|
||||
midKeys.push(`mid:${i}`);
|
||||
arbiter.addNode(`mid:${i}`, 'group');
|
||||
}
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
|
||||
if (configKind === 0) {
|
||||
arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
|
||||
} else {
|
||||
arbiter.setRelationConfig('can_read', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Direct edges: user → doc
|
||||
const directEdges = Math.max(1, Math.floor(users / 2) + 1);
|
||||
for (let i = 0; i < directEdges; i++) {
|
||||
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
||||
arbiter.addRelation(u, 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
// Memberships: user → mid
|
||||
for (let i = 0; i < mids; i++) {
|
||||
if (Math.random() < 0.7) {
|
||||
const u = userKeys[Math.floor(Math.random() * userKeys.length)];
|
||||
arbiter.addRelation(u, 'member_of', midKeys[i], { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
}
|
||||
// Terminal: mid → doc
|
||||
for (let i = 0; i < mids; i++) {
|
||||
if (Math.random() < 0.7) {
|
||||
arbiter.addRelation(midKeys[i], 'viewer', 'doc:1', { possibility: POS[Math.floor(Math.random() * POS.length)] });
|
||||
}
|
||||
}
|
||||
|
||||
arbiter.enableCondensedSnapshot();
|
||||
const buffer = serializeArbiterSnapshot(arbiter);
|
||||
// Proper restore path: rebuilds the condensed indices over the graph
|
||||
const restored = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => factory());
|
||||
|
||||
const checks = [];
|
||||
for (const u of userKeys) {
|
||||
checks.push([u, 'can_read', 'doc:1']);
|
||||
}
|
||||
return { original: arbiter, restored, checks };
|
||||
}
|
||||
|
||||
describe('Condensed snapshot round trip (rigor)', () => {
|
||||
it('ROUND-TRIP PARITY: restored snapshot answers checks identically', async () => {
|
||||
async function check(seedCase) {
|
||||
const { original, restored, checks } = buildGraph(seedCase);
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const before = original.check(u, rel, obj);
|
||||
const after = restored.check(u, rel, obj);
|
||||
if (Math.abs(before.possibility - after.possibility) > EPS) {
|
||||
fail(`parity ${u} ${rel} ${obj}: original=${before.possibility}, restored=${after.possibility}`);
|
||||
}
|
||||
}
|
||||
return { checked: checks.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
users: rigor.gen.int(1, 5),
|
||||
mids: rigor.gen.int(0, 4),
|
||||
configKind: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('snapshot-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'snapshot-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'snapshot-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `ROUND-TRIP PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('READ-ONLY ENFORCEMENT: restored snapshot rejects mutations, keeps reading', async () => {
|
||||
async function check(seedCase) {
|
||||
const { restored, checks } = buildGraph(seedCase);
|
||||
if (restored._snapshotReadOnly !== true) {
|
||||
fail('restored snapshot must be read-only');
|
||||
}
|
||||
// Reads still work
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const r = restored.check(u, rel, obj);
|
||||
if (r.possibility < 0 || r.possibility > 1) {
|
||||
fail(`read on snapshot out of bounds: ${r.possibility}`);
|
||||
}
|
||||
}
|
||||
// Mutations must not corrupt the snapshot
|
||||
let threw = false;
|
||||
try {
|
||||
restored.addRelation('user:0', 'viewer', 'doc:1', { possibility: 1 });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
if (!threw) {
|
||||
// If it didn't throw, the mutation must not have changed answers
|
||||
for (const [u, rel, obj] of checks) {
|
||||
const r = restored.check(u, rel, obj);
|
||||
if (r.possibility < 0 || r.possibility > 1) {
|
||||
fail(`mutated snapshot returned bad result: ${r.possibility}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { readOnly: restored._snapshotReadOnly };
|
||||
}
|
||||
|
||||
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, 3),
|
||||
configKind: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('readonly-enforced', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'snapshot-readonly' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'readonly-enforced');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `READ-ONLY ENFORCEMENT violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* rigor/snapshot-quantization-parity.test.js — condensed-snapshot round-trip
|
||||
* parity for non-dyadic possibility values.
|
||||
*
|
||||
* The condensed snapshot encodes each edge possibility as a 16-bit uniform
|
||||
* quantizer on the 65535 scale (_floatToBits = round(p * 65535),
|
||||
* _bitsToFloat = bits / 65535). Contract:
|
||||
* - endpoints 0 and 1 are exact; every other value round-trips with
|
||||
* absolute error <= 0.5 / 65535 (~7.63e-6).
|
||||
* - restored values never leave [0, 1].
|
||||
* - binary-mode decisions can only flip when the live value sits inside
|
||||
* the quantization band of the threshold; outside the band decisions
|
||||
* must agree exactly.
|
||||
* - restoring the same buffer twice is deterministic; snapshot-of-snapshot
|
||||
* (serialize a restored arbiter, restore again) preserves values.
|
||||
* - the graph binary round-trips self-consistently (toBinary(restored)
|
||||
* is byte-stable across generations).
|
||||
*
|
||||
* The oracle is the live arbiter's own pre-enable check results.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
|
||||
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';
|
||||
|
||||
const QUANT_STEP = 0.5 / 65535; // max absolute quantization error
|
||||
const TOL = QUANT_STEP + 1e-9;
|
||||
|
||||
const NONDYADIC = [0.1, 0.3, 0.7, 0.9, 0.111, 0.333, 0.999, 0.001, 0.8999999, 0.5000001];
|
||||
|
||||
function nodeKey(id) {
|
||||
if (id < 2) return `u:${id}`;
|
||||
if (id < 3) return 'g:0';
|
||||
return `doc:${id - 3}`;
|
||||
}
|
||||
|
||||
function buildGraph(edges, values) {
|
||||
const arb = new Arbiter();
|
||||
for (let i = 0; i < 6; i++) arb.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc');
|
||||
arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
|
||||
arb.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'reads', direction: 'out' }
|
||||
]
|
||||
});
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), { possibility: values[i] });
|
||||
}
|
||||
return arb;
|
||||
}
|
||||
|
||||
const QUERIES = [];
|
||||
for (const u of [0, 1]) {
|
||||
for (const d of [3, 4, 5]) {
|
||||
QUERIES.push(['can_read', u, d]);
|
||||
QUERIES.push(['can_access', u, d]);
|
||||
}
|
||||
}
|
||||
|
||||
function roundTripReport(edges, values) {
|
||||
const live = buildGraph(edges, values);
|
||||
const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||
|
||||
live.enableCondensedSnapshot();
|
||||
const buffer = serializeArbiterSnapshot(live);
|
||||
|
||||
const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
||||
const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||
|
||||
const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
|
||||
const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||
|
||||
const buffer2 = serializeArbiterSnapshot(r1);
|
||||
const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter());
|
||||
const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility);
|
||||
|
||||
return { before, after1, after1b, after2, graphBytes: live.snapshotGraph.toBinary().byteLength };
|
||||
}
|
||||
|
||||
describe('Condensed snapshot quantization parity (rigor)', () => {
|
||||
it('FIXED VALUES: quantization error band, bounds, determinism, snapshot-of-snapshot', () => {
|
||||
const edges = [
|
||||
[0, 'owner', 3],
|
||||
[1, 'owner', 4],
|
||||
[0, 'member_of', 2],
|
||||
[2, 'reads', 3],
|
||||
[1, 'member_of', 2],
|
||||
[2, 'reads', 4],
|
||||
[0, 'reads', 5],
|
||||
[1, 'owner', 5]
|
||||
];
|
||||
const values = [0.9, 0.1, 0.7, 0.333, 0.999, 0.111, 0.5000001, 0.001];
|
||||
const r = roundTripReport(edges, values);
|
||||
|
||||
for (let i = 0; i < r.before.length; i++) {
|
||||
const live = r.before[i];
|
||||
assert.ok(
|
||||
Math.abs(r.after1[i] - live) <= TOL,
|
||||
`query ${i}: live=${live} restored=${r.after1[i]} exceeds tolerance ${TOL}`
|
||||
);
|
||||
assert.ok(r.after1[i] >= 0 && r.after1[i] <= 1, `restored value ${r.after1[i]} outside [0,1]`);
|
||||
}
|
||||
assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic');
|
||||
assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values');
|
||||
|
||||
const g1 = buildGraph(edges, values);
|
||||
g1.enableCondensedSnapshot();
|
||||
const b1 = g1.snapshotGraph.toBinary();
|
||||
const g2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g1), {}, () => new Arbiter());
|
||||
const b2 = g2.snapshotGraph.toBinary();
|
||||
const g3 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g2), {}, () => new Arbiter());
|
||||
const b3 = g3.snapshotGraph.toBinary();
|
||||
assert.equal(b2.byteLength, b3.byteLength, 'reserialized graph binary is byte-stable');
|
||||
});
|
||||
|
||||
it('PROPERTY CAMPAIGN: random graphs keep parity under quantization and threshold decisions', async () => {
|
||||
const edgeSet = rigor.gen.oneOf([
|
||||
[[0, 'owner', 3]],
|
||||
[[0, 'owner', 3], [1, 'owner', 4]],
|
||||
[[0, 'owner', 3], [0, 'member_of', 2], [2, 'reads', 3]],
|
||||
[[0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4]],
|
||||
[[0, 'owner', 3], [1, 'owner', 4], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4], [0, 'reads', 5], [1, 'owner', 5]],
|
||||
[[0, 'owner', 5], [2, 'reads', 5], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3]]
|
||||
]);
|
||||
const valuesGen = rigor.gen.array(rigor.gen.oneOf(NONDYADIC), 0, 12);
|
||||
|
||||
const result = await rigor.campaign(
|
||||
[rigor.fn('roundtrip', (edges, values) => roundTripReport(edges, values),
|
||||
rigor.args(edgeSet, valuesGen))],
|
||||
rigor.crucible([
|
||||
rigor.invariant('values within quantization tolerance', (ctx) => {
|
||||
const { before, after1 } = ctx.actual;
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
if (Math.abs(after1[i] - before[i]) > TOL) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
rigor.invariant('restored values stay in [0,1]', (ctx) => {
|
||||
const { after1 } = ctx.actual;
|
||||
for (const v of after1) {
|
||||
if (!(v >= 0 && v <= 1)) return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
rigor.invariant('deterministic restore', (ctx) => {
|
||||
const { after1, after1b } = ctx.actual;
|
||||
return after1.every((v, i) => Math.abs(v - after1b[i]) <= TOL);
|
||||
}),
|
||||
rigor.invariant('snapshot-of-snapshot preserves values', (ctx) => {
|
||||
const { after1, after2 } = ctx.actual;
|
||||
return after1.every((v, i) => Math.abs(v - after2[i]) <= TOL);
|
||||
})
|
||||
])
|
||||
).run({ effort: 300, seed: 'snapshot-quantization-parity' });
|
||||
|
||||
const inv = result.crucibleVerdict;
|
||||
assert.equal(inv.passed, true, [
|
||||
`quantization parity violated in ${inv.failureCount} cases:`,
|
||||
...result.failures.slice(0, 3).map((f) =>
|
||||
` [${f.invariant}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)}`
|
||||
)
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('THRESHOLD BAND: binary decisions only flip inside the quantization band', () => {
|
||||
const thresholds = [0.2, 0.5, 0.8, 0.9];
|
||||
const edges = [[0, 'owner', 3], [1, 'owner', 4]];
|
||||
for (const t of thresholds) {
|
||||
for (const v of NONDYADIC) {
|
||||
const live = buildGraph([[0, 'owner', 3]], [v]);
|
||||
const before = live.check('u:0', 'can_read', 'doc:3').possibility;
|
||||
const beforeDecision = before >= t;
|
||||
live.enableCondensedSnapshot();
|
||||
const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(live), {}, () => new Arbiter());
|
||||
const after = restored.check('u:0', 'can_read', 'doc:3').possibility;
|
||||
const afterDecision = after >= t;
|
||||
const delta = Math.abs(before - t);
|
||||
if (delta >= QUANT_STEP) {
|
||||
assert.equal(afterDecision, beforeDecision,
|
||||
`threshold ${t}: value ${v} (live ${before}, restored ${after}) flips outside the band`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* rigor/traversal-parity.test.js — js-rigor property tests for chain
|
||||
* direction semantics, TTU multi-tuple aggregation, and the update path.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - CHAIN DIRECTION PARITY: for 2-3 step chains with arbitrary
|
||||
* out/in directions, the engine's traversal (getRelationsFromSrc /
|
||||
* getRelationsToDst) matches a BFS oracle: step 'out' walks
|
||||
* src -r-> dst, step 'in' walks src <-r- dst; path possibility is
|
||||
* the MIN along the path; multiple paths take the MAX.
|
||||
* - TTU MULTI-TUPLE PARITY: possibility = max over tuple edges of
|
||||
* min(tuplePossibility, memberPossibility) where membership is a
|
||||
* DIRECT user->group lookup.
|
||||
* - UPDATE PATH PARITY: overwriting an existing tuple
|
||||
* (addRelation on an existing src/rel/dst) is last-write-wins:
|
||||
* the check reflects the new possibility, the engine keeps exactly
|
||||
* one tuple, and indices stay consistent — for direct, chain, and
|
||||
* TTU configs alike.
|
||||
*/
|
||||
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];
|
||||
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
next() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Edge universe: every possible directed edge on the 4-node graph
|
||||
// (u, m1, m2, o) for relations r1 and r2.
|
||||
const EDGE_UNIVERSE = {
|
||||
r1: [
|
||||
['user:alice', 'mid:1'],
|
||||
['mid:1', 'user:alice'],
|
||||
['mid:1', 'mid:2'],
|
||||
['doc:1', 'mid:2'],
|
||||
['mid:2', 'doc:1']
|
||||
],
|
||||
r2: [
|
||||
['mid:1', 'doc:1'],
|
||||
['doc:1', 'mid:1'],
|
||||
['mid:2', 'user:alice'],
|
||||
['user:alice', 'mid:2'],
|
||||
['user:alice', 'doc:1'],
|
||||
['mid:2', 'mid:1']
|
||||
]
|
||||
};
|
||||
|
||||
function randomEdges(rng, density = 0.5) {
|
||||
const edges = [];
|
||||
for (const rel of ['r1', 'r2']) {
|
||||
for (const [src, dst] of EDGE_UNIVERSE[rel]) {
|
||||
if (rng.next() < density) {
|
||||
edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function edgeKey(src, rel, dst) {
|
||||
return `${src}|${rel}|${dst}`;
|
||||
}
|
||||
|
||||
function chainOracle(steps, edges) {
|
||||
const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]]));
|
||||
let frontier = new Map([['user:alice', 1.0]]);
|
||||
for (const step of steps) {
|
||||
const { relation, direction } = step;
|
||||
const next = new Map();
|
||||
for (const [node, p] of frontier) {
|
||||
for (const [key, ep] of em) {
|
||||
const [s, r, d] = key.split('|');
|
||||
if (r !== relation) continue;
|
||||
const matches = direction === 'out' ? s === node : d === node;
|
||||
if (!matches) continue;
|
||||
const nxt = direction === 'out' ? d : s;
|
||||
if (nxt === node) continue; // no self-loop progress
|
||||
const np = Math.min(p, ep);
|
||||
const cur = next.get(nxt);
|
||||
if (cur === undefined || np > cur) next.set(nxt, np);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) break;
|
||||
}
|
||||
return frontier.get('doc:1') || 0;
|
||||
}
|
||||
|
||||
function makeChainConfig(steps) {
|
||||
return { type: 'chain', steps };
|
||||
}
|
||||
|
||||
function applyEdges(arb, edges, mode) {
|
||||
for (const [src, rel, dst, p] of edges) {
|
||||
if (mode === 'add') arb.addRelation(src, rel, dst, { possibility: p });
|
||||
else arb.removeRelation(src, rel, dst);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArbiter() {
|
||||
const arb = new Arbiter();
|
||||
for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
|
||||
arb.setRelationConfig('r1', { type: 'direct' });
|
||||
arb.setRelationConfig('r2', { type: 'direct' });
|
||||
arb.setRelationConfig('owner', { type: 'direct' });
|
||||
arb.setRelationConfig('member_of', { type: 'direct' });
|
||||
return arb;
|
||||
}
|
||||
|
||||
describe('Traversal semantics parity (rigor)', () => {
|
||||
it('CHAIN DIRECTION PARITY: arbitrary out/in step mixes match the BFS oracle', async () => {
|
||||
async function check({ seed, dirs, threeSteps }) {
|
||||
const rng = mulberry32(seed);
|
||||
const edges = randomEdges(rng);
|
||||
const arb = buildArbiter();
|
||||
const d = dirs.split('-');
|
||||
const steps = threeSteps
|
||||
? [
|
||||
{ relation: 'r1', direction: d[0] },
|
||||
{ relation: 'r2', direction: d[1] },
|
||||
{ relation: 'r1', direction: d[2 % 2] }
|
||||
]
|
||||
: [
|
||||
{ relation: 'r1', direction: d[0] },
|
||||
{ relation: 'r2', direction: d[1] }
|
||||
];
|
||||
arb.setRelationConfig('target', makeChainConfig(steps));
|
||||
applyEdges(arb, edges, 'add');
|
||||
|
||||
const expectedP = chainOracle(steps, edges);
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(res.possibility - expectedP) > EPS) {
|
||||
fail(`chain mismatch dirs=${JSON.stringify(dirs)} steps=${steps.length} p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
|
||||
// Mutations must keep parity
|
||||
const rels = ['r1', 'r2'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rel = rels[Math.floor(rng.next() * 2)];
|
||||
const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
|
||||
const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
|
||||
if (idx !== -1) {
|
||||
arb.removeRelation(src, rel, dst);
|
||||
edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, rel, dst, { possibility: p });
|
||||
edges.push([src, rel, dst, p]);
|
||||
}
|
||||
const eP = chainOracle(steps, edges);
|
||||
const r = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(r.possibility - eP) > EPS) {
|
||||
fail(`chain mutation mismatch dirs=${JSON.stringify(dirs)} p=${eP} got=${r.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
}
|
||||
return { steps: steps.length };
|
||||
}
|
||||
|
||||
const dirPairs = ['out-out', 'out-in', 'in-out', 'in-in'];
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 100000),
|
||||
dirs: rigor.gen.oneOf(dirPairs),
|
||||
threeSteps: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('chain-direction-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1500, seed: 'traversal-chain-direction' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-direction-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `chain direction parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('TTU MULTI-TUPLE PARITY: max over tuples of min(tuple, membership)', async () => {
|
||||
async function check({ seed }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
const tuples = [
|
||||
['doc:1', 'group:eng'],
|
||||
['doc:1', 'group:design']
|
||||
];
|
||||
arb.addNode('group:eng', 'group');
|
||||
arb.addNode('group:design', 'group');
|
||||
const edges = [];
|
||||
let expectedP = 0;
|
||||
for (const [doc, grp] of tuples) {
|
||||
if (rng.next() < 0.7) {
|
||||
const tp = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(doc, 'owner', grp, { possibility: tp });
|
||||
edges.push([doc, 'owner', grp, tp]);
|
||||
let mp = 0;
|
||||
if (rng.next() < 0.8) {
|
||||
mp = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation('user:alice', 'member_of', grp, { possibility: mp });
|
||||
edges.push(['user:alice', 'member_of', grp, mp]);
|
||||
}
|
||||
expectedP = Math.max(expectedP, Math.min(tp, mp));
|
||||
}
|
||||
}
|
||||
const res = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(res.possibility - expectedP) > EPS) {
|
||||
fail(`ttu mismatch p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
|
||||
// Mutation: flip one membership
|
||||
const grp = tuples[Math.floor(rng.next() * 2)][1];
|
||||
const hasMember = edges.some(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
||||
if (hasMember) {
|
||||
arb.removeRelation('user:alice', 'member_of', grp);
|
||||
const idx = edges.findIndex(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
|
||||
if (idx !== -1) edges.splice(idx, 1);
|
||||
} else {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation('user:alice', 'member_of', grp, { possibility: p });
|
||||
edges.push(['user:alice', 'member_of', grp, p]);
|
||||
}
|
||||
let eP = 0;
|
||||
const tupleMap = new Map();
|
||||
for (const [s, r, d, p] of edges) {
|
||||
if (r === 'owner') tupleMap.set(d, p);
|
||||
}
|
||||
for (const [grp2, tp] of tupleMap) {
|
||||
const mem = edges.find(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp2);
|
||||
eP = Math.max(eP, Math.min(tp, mem ? mem[3] : 0));
|
||||
}
|
||||
const r2 = arb.check('user:alice', 'target', 'doc:1', {});
|
||||
if (Math.abs(r2.possibility - eP) > EPS) {
|
||||
fail(`ttu mutation mismatch p=${eP} got=${r2.possibility} edges=${JSON.stringify(edges)}`);
|
||||
}
|
||||
return { edges: edges.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({ seed: rigor.gen.int(1, 100000) })
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('ttu-multi-tuple-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 1200, seed: 'traversal-ttu-multituple' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-multi-tuple-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `ttu parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('UPDATE PATH PARITY: overwrites are last-write-wins with a single tuple and fresh checks', async () => {
|
||||
async function check({ seed, kind }) {
|
||||
const rng = mulberry32(seed);
|
||||
const arb = buildArbiter();
|
||||
let target;
|
||||
if (kind === 0) {
|
||||
arb.setRelationConfig('target', { type: 'direct', relation: 'r1' });
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
||||
} else if (kind === 1) {
|
||||
arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
|
||||
} else {
|
||||
arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
|
||||
arb.addNode('group:eng', 'group');
|
||||
target = { check: (u, o) => arb.check(u, 'target', o), base: 'owner' };
|
||||
}
|
||||
|
||||
const src = kind === 2 ? 'doc:1' : 'user:alice';
|
||||
const dst = kind === 0 ? 'mid:1' : kind === 1 ? 'mid:1' : 'group:eng';
|
||||
// Warm with the first value
|
||||
const p0 = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, target.base, dst, { possibility: p0 });
|
||||
|
||||
let lastP = p0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = POS[Math.floor(rng.next() * POS.length)];
|
||||
arb.addRelation(src, target.base, dst, { possibility: p });
|
||||
lastP = p;
|
||||
}
|
||||
|
||||
// Exactly one tuple in the engine (no duplicates from overwrites)
|
||||
const count = arb.relations.filter(r => r.rel === target.base && r.src === arb.resolveNodeId(src) && r.dst === arb.resolveNodeId(dst)).length; if (count !== 1) {
|
||||
fail(`overwrite left ${count} tuples for ${target.base} (kind=${kind})`);
|
||||
}
|
||||
|
||||
if (kind === 0) {
|
||||
const res = arb.check(src, 'target', kind === 0 ? 'mid:1' : 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`update path stale: expected ${lastP}, got ${res.possibility} (kind=${kind})`);
|
||||
}
|
||||
} else if (kind === 1) {
|
||||
// chain: seed an r2 edge so the path exists
|
||||
arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });
|
||||
const res = arb.check(src, 'target', 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`chain update path stale: expected ${lastP}, got ${res.possibility}`);
|
||||
}
|
||||
} else {
|
||||
arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: 1 });
|
||||
const res = arb.check('user:alice', 'target', 'doc:1');
|
||||
if (Math.abs(res.possibility - lastP) > EPS) {
|
||||
fail(`ttu update path stale: expected ${lastP}, got ${res.possibility}`);
|
||||
}
|
||||
}
|
||||
return { lastP };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
seed: rigor.gen.int(1, 50000),
|
||||
kind: rigor.gen.int(0, 2)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('update-path-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'traversal-update-path' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'update-path-parity');
|
||||
assert.ok(inv, 'invariant missing');
|
||||
assert.equal(inv.passed, true, `update path parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* rigor/tuple-to-userset-rule.test.js — js-rigor property tests for TupleToUsersetRule.
|
||||
*
|
||||
* TupleToUsersetRule grants access via two-step pattern: object has tupleset
|
||||
* relation to an intermediate, user has computed relation to that intermediate.
|
||||
* Properties verified in the 'direct join' mode (computedRelation.type='direct'):
|
||||
*
|
||||
* - No tuples → possibility=0, reason='no_valid_intermediate_paths'
|
||||
* - One tuple + one matching direct edge → possibility = min(tuplePoss, edgePoss)
|
||||
* - Multiple tuples → max fused (default OWA weights [1,0,0,...])
|
||||
* - Cycle detection → reason='cycle', possibility=0
|
||||
* - reverse=true routes the lookup via the user side, not the object side
|
||||
* - earlyExitThreshold triggers early return when a path exceeds it
|
||||
* - result.possibility ∈ [0, 1] always
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { TupleToUsersetRule } from '../../src/authorization/rules/TupleToUsersetRule.js';
|
||||
|
||||
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||||
|
||||
/**
|
||||
* Build an arbiter stub. relationManager.getRelationsFromSrc/getRelationsToDst
|
||||
* return relations from a static table. relationConfigs.get returns
|
||||
* { type: 'direct' } for the computedRelation to enable useDirectJoin branch.
|
||||
* Also exposes a no-op authChecker.check for any fallback path.
|
||||
*/
|
||||
function makeArbiter({ tuplesetRels, directEdges, keyMap, computedRelation = 'member' }) {
|
||||
const relationConfigs = new Map([
|
||||
['direct', { type: 'direct' }],
|
||||
[computedRelation, { type: 'direct' }] // enables useDirectJoin for this computed relation
|
||||
]);
|
||||
return {
|
||||
relationManager: {
|
||||
shouldUseRelationGraphTraversal() { return false; },
|
||||
getDirectRelation(srcId, rel, dstId) {
|
||||
return directEdges.get(`${srcId}|${rel}|${dstId}`) ?? null;
|
||||
},
|
||||
getRelationsFromSrc(srcId, relName) {
|
||||
return tuplesetRels.get(`${srcId}|${relName}`) ?? [];
|
||||
},
|
||||
getRelationsToDst(dstId, relName) {
|
||||
return tuplesetRels.get(`_toDst|${dstId}|${relName}`) ?? [];
|
||||
}
|
||||
},
|
||||
relationConfigs,
|
||||
keyManager: {
|
||||
_getRelationId(name) {
|
||||
return `__relId:${name}`;
|
||||
}
|
||||
},
|
||||
resolveKey(nodeId) { return keyMap.get(nodeId) ?? null; },
|
||||
resolveNodeId(key) { return keyMap.get(`_rev:${key}`) ?? null; },
|
||||
// authChecker.check is unreachable in useDirectJoin mode, but stub it for safety
|
||||
authChecker: { check() { return { possibility: 0, reliability: 1.0, reason: 'no_authChecker' }; } }
|
||||
};
|
||||
}
|
||||
|
||||
describe('TupleToUsersetRule evaluation (rigor)', () => {
|
||||
it('no tuples → possibility=0, reason=no_valid_intermediate_paths', async () => {
|
||||
async function check(userKey, objectKey) {
|
||||
const arbiter = makeArbiter({
|
||||
tuplesetRels: new Map(),
|
||||
directEdges: new Map(),
|
||||
keyMap: new Map([[1, 'u1'], [99, 'o99']])
|
||||
});
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, userKey, 99, objectKey,
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (result.possibility !== 0) {
|
||||
throw new Error(`possibility=${result.possibility}, expected 0`);
|
||||
}
|
||||
if (result.reason !== 'no_valid_intermediate_paths') {
|
||||
throw new Error(`reason=${result.reason}, expected 'no_valid_intermediate_paths'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.string(1, 30),
|
||||
rigor.gen.string(1, 30)
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('no-tuples', ({ 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 === 'no-tuples');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `no-tuples contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('one tuple + matching direct edge → possibility = min(tuplePoss, edgePoss)', async () => {
|
||||
async function check(tuplePoss, edgePoss) {
|
||||
// Object 99 has tuple owner → 50. User 1 has direct edge member → 50.
|
||||
const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
|
||||
// For useDirectJoin mode, computedEdges come from getRelationsFromSrc(userId, computedRelation).
|
||||
// The direct edge between user and intermediate must be present there, not just in directEdges.
|
||||
const directEdges = new Map();
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]],
|
||||
[`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: edgePoss }]]
|
||||
]);
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
const expected = Math.min(tuplePoss, edgePoss);
|
||||
if (result.possibility !== expected) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${expected} (min of ${tuplePoss}, ${edgePoss})`);
|
||||
}
|
||||
if (result.reason !== 'tuple_to_userset_found') {
|
||||
throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('min-fusion', ({ 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 === 'min-fusion');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `min-fusion contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('multiple tuples → fused via max (default OWA weights)', async () => {
|
||||
async function check(tuplePoss1, tuplePoss2, edgePoss1, edgePoss2) {
|
||||
// Object 99 has two tuples: owner → 50, owner → 51. User has matching edges.
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [
|
||||
{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss1 },
|
||||
{ src: 99, rel: 'owner', dst: 51, possibility: tuplePoss2 }
|
||||
]],
|
||||
[`1|member`, [
|
||||
{ src: 1, rel: 'member', dst: 50, possibility: edgePoss1 },
|
||||
{ src: 1, rel: 'member', dst: 51, possibility: edgePoss2 }
|
||||
]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
const path1 = Math.min(tuplePoss1, edgePoss1);
|
||||
const path2 = Math.min(tuplePoss2, edgePoss2);
|
||||
const expected = Math.max(path1, path2);
|
||||
if (result.possibility !== expected) {
|
||||
throw new Error(`possibility=${result.possibility}, expected ${expected} (max of ${path1}, ${path2})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 }),
|
||||
rigor.gen.float({ min: 0.01, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('multi-tuple-max', ({ 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 === 'multi-tuple-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `multi-tuple-max contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('result.possibility ∈ [0, 1] always', async () => {
|
||||
async function check(tuplePoss, edgePoss) {
|
||||
const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
|
||||
const directEdges = new Map([[`1|member|50`, { possibility: edgePoss }]]);
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
if (result.possibility < 0 || result.possibility > 1) {
|
||||
throw new Error(`possibility=${result.possibility} outside [0,1]`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check,
|
||||
rigor.args(
|
||||
rigor.gen.float({ min: 0, max: 1 }),
|
||||
rigor.gen.float({ min: 0, max: 1 })
|
||||
)
|
||||
)],
|
||||
rigor.crucible([
|
||||
rigor.invariant('possibility-bounded', ({ 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 === 'possibility-bounded');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('early exit: high-strength path triggers early_return reason', async () => {
|
||||
async function check() {
|
||||
// 3 tuples, one with very high strength (triggers early exit at threshold=0.95)
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [
|
||||
{ src: 99, rel: 'owner', dst: 50, possibility: 0.99 }, // high — should trigger
|
||||
{ src: 99, rel: 'owner', dst: 51, possibility: 0.5 },
|
||||
{ src: 99, rel: 'owner', dst: 52, possibility: 0.3 }
|
||||
]],
|
||||
[`1|member`, [
|
||||
{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }, // combined = min(0.99, 1.0) = 0.99
|
||||
{ src: 1, rel: 'member', dst: 51, possibility: 0.4 },
|
||||
{ src: 1, rel: 'member', dst: 52, possibility: 0.2 }
|
||||
]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51'], [52, 'i52']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
new Set(),
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// The early exit returns possibility=0.99 (the combined path that triggered it)
|
||||
if (result.possibility < 0.95) {
|
||||
throw new Error(`early exit should return high-strength path; got ${result.possibility}`);
|
||||
}
|
||||
// The reason stays 'tuple_to_userset_found' for a single-path early exit —
|
||||
// the 'early_exit_direct_path' string is only set in evaluationMeta.evaluationType.
|
||||
if (result.reason !== 'tuple_to_userset_found') {
|
||||
throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
|
||||
}
|
||||
// But the meta.evaluation.evaluationType should signal early_exit_direct_path
|
||||
const evalType = result.meta?.evaluation?.evaluationType;
|
||||
if (evalType !== 'early_exit_direct_path') {
|
||||
throw new Error(`meta.evaluation.evaluationType=${evalType}, expected 'early_exit_direct_path'`);
|
||||
}
|
||||
// And the performance stats should show earlyExits++
|
||||
if (rule.performanceStats.earlyExits !== 1) {
|
||||
throw new Error(`rule.performanceStats.earlyExits=${rule.performanceStats.earlyExits}, expected 1`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('early-exit', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'early-exit');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `early-exit violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('cycle detection: visited set causes reason=cycle', async () => {
|
||||
async function check() {
|
||||
// For cycle detection to fire, we need joinMode === 'computed', which
|
||||
// requires computedEdges.length < tuples.length. With 1 tuple and 1
|
||||
// computed edge, computedEdges.length === 1, tuples.length === 1, so
|
||||
// joinMode === 'tuples' and the cycle check on line 275-283 runs.
|
||||
// Set up: 1 tuple from object → 50, 1 computed edge user → 50, both
|
||||
// populated. The visited set contains the cycle key, so when the
|
||||
// tuples-loop processes intermediate 50, it sees the cycle.
|
||||
const allRels = new Map([
|
||||
[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: 0.9 }]],
|
||||
[`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }]]
|
||||
]);
|
||||
const directEdges = new Map();
|
||||
const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
|
||||
const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
|
||||
const rule = new TupleToUsersetRule(arbiter);
|
||||
// Pre-populate visited with the cycle key — computedRelationId is "__relId:member"
|
||||
const visited = new Set([`1|__relId:member|50`]);
|
||||
const result = rule.evaluate(
|
||||
1, 'u1', 99, 'o99',
|
||||
{ type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
|
||||
visited,
|
||||
'whatever',
|
||||
{ includeMeta: true }
|
||||
);
|
||||
// Either reason='cycle' directly, OR the path possibility becomes 0 because
|
||||
// the cycle-detected edge yields res.possibility=0.
|
||||
// Looking at production: when res.reason='cycle', reasons array contains 'cycle',
|
||||
// and at end of _buildFinalResult: reason = reasons.includes('cycle') ? 'cycle' : ...
|
||||
// So result.reason SHOULD be 'cycle'.
|
||||
if (result.reason !== 'cycle') {
|
||||
throw new Error(`cycle reason=${result.reason}, expected 'cycle'`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[rigor.fn('check', check, rigor.args())],
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-detection', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 200 });
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-detection');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `cycle-detection violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* rigor/zanzibar-consistency.test.js — js-rigor property tests for
|
||||
* authorization state consistency under mutation, reconfiguration and
|
||||
* value collection.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - TTU MUTATION: removing either the membership edge or the tupleset
|
||||
* edge revokes a previously-granting TTU check; restoring the edge
|
||||
* re-grants (no stale cache in either direction).
|
||||
* - CHAIN MUTATION: removing an intermediate edge in a chain revokes;
|
||||
* re-adding re-grants.
|
||||
* - CONFIG CHANGE: reconfiguring the same relation (direct → chain)
|
||||
* changes the outcome exactly as the new config dictates, without
|
||||
* stale results from the old config.
|
||||
* - THRESHOLD: a minPossibility filter excludes paths below the
|
||||
* threshold; results never exceed the strongest surviving path.
|
||||
* - VALUE COLLECTION COMPLETENESS (regression): every parallel chain
|
||||
* path contributes its value — the sum of authorized balances equals
|
||||
* the sum of ALL path values, even when one path is weaker than
|
||||
* another (this property fails on the old dedup-before-collect bug).
|
||||
* - REPEATED CHECK DETERMINISM: identical checks return identical
|
||||
* results across repetitions (no hidden state drift).
|
||||
*/
|
||||
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 POSSIBILITIES = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe('Authorization state consistency (rigor)', () => {
|
||||
it('TTU MUTATION: membership or tupleset removal revokes; restore re-grants', async () => {
|
||||
async function check({ pm, po, mutate }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:alice', 'group:eng', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'owner',
|
||||
computedRelation: 'member_of'
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
|
||||
arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po });
|
||||
|
||||
const before = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
const expected = Math.min(pm, po);
|
||||
if (Math.abs(before.possibility - expected) > EPS) {
|
||||
fail(`setup: expected ${expected}, got ${before.possibility}`);
|
||||
}
|
||||
|
||||
// Remove one edge (warm caches first with a granting check)
|
||||
if (mutate === 0) {
|
||||
arbiter.removeRelation('user:alice', 'member_of', 'group:eng');
|
||||
} else {
|
||||
arbiter.removeRelation('doc:1', 'owner', 'group:eng');
|
||||
}
|
||||
const revoked = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (revoked.possibility !== 0) {
|
||||
fail(`revoked TTU still grants: ${revoked.possibility}`);
|
||||
}
|
||||
|
||||
// Restore the edge → grant again
|
||||
if (mutate === 0) {
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
|
||||
} else {
|
||||
arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po });
|
||||
}
|
||||
const restored = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (Math.abs(restored.possibility - expected) > EPS) {
|
||||
fail(`restored TTU: expected ${expected}, got ${restored.possibility}`);
|
||||
}
|
||||
return { before, revoked, restored };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pm: rigor.gen.oneOf(POSSIBILITIES),
|
||||
po: rigor.gen.oneOf(POSSIBILITIES),
|
||||
mutate: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('ttu-mutation', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'consistency-ttu-mutation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-mutation');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `TTU MUTATION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('CHAIN MUTATION: removing an intermediate edge revokes; re-adding re-grants', async () => {
|
||||
async function check({ p1, p2, p3 }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:alice', 'group:eng', 'group:org', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 });
|
||||
arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 });
|
||||
arbiter.addRelation('group:org', 'viewer', 'doc:1', { possibility: p3 });
|
||||
|
||||
const expected = Math.min(p1, p2, p3);
|
||||
const before = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (Math.abs(before.possibility - expected) > EPS) {
|
||||
fail(`setup: expected ${expected}, got ${before.possibility}`);
|
||||
}
|
||||
|
||||
// Break the middle hop
|
||||
arbiter.removeRelation('group:eng', 'member_of', 'group:org');
|
||||
const revoked = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (revoked.possibility !== 0) {
|
||||
fail(`broken chain still grants: ${revoked.possibility}`);
|
||||
}
|
||||
|
||||
// Rebuild the hop
|
||||
arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 });
|
||||
const restored = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (Math.abs(restored.possibility - expected) > EPS) {
|
||||
fail(`restored chain: expected ${expected}, got ${restored.possibility}`);
|
||||
}
|
||||
return { before, revoked, restored };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p1: rigor.gen.oneOf(POSSIBILITIES),
|
||||
p2: rigor.gen.oneOf(POSSIBILITIES),
|
||||
p3: rigor.gen.oneOf(POSSIBILITIES)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('chain-mutation', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'consistency-chain-mutation' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-mutation');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `CHAIN MUTATION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('CONFIG CHANGE: reconfiguring a relation flips outcomes exactly as the new config dictates', async () => {
|
||||
async function check({ pEdge, pMember, configOrder }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:alice', 'group:eng', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: pEdge });
|
||||
arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pMember });
|
||||
arbiter.addRelation('group:eng', 'viewer', 'doc:1', { possibility: pMember });
|
||||
|
||||
const directConfig = () => arbiter.setRelationConfig('can_access', { type: 'direct', relation: 'viewer' });
|
||||
const chainConfig = () => arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
const first = configOrder === 0 ? directConfig() : chainConfig();
|
||||
const directResult = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
|
||||
const second = configOrder === 0 ? chainConfig() : directConfig();
|
||||
const afterReconfig = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
|
||||
const expectedDirect = pEdge;
|
||||
const expectedChain = Math.min(pMember, pMember);
|
||||
if (first && second) {
|
||||
if (configOrder === 0) {
|
||||
if (Math.abs(directResult.possibility - expectedDirect) > EPS) {
|
||||
fail(`direct config: expected ${expectedDirect}, got ${directResult.possibility}`);
|
||||
}
|
||||
if (Math.abs(afterReconfig.possibility - expectedChain) > EPS) {
|
||||
fail(`after reconfig to chain: expected ${expectedChain}, got ${afterReconfig.possibility}`);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(directResult.possibility - expectedChain) > EPS) {
|
||||
fail(`chain config: expected ${expectedChain}, got ${directResult.possibility}`);
|
||||
}
|
||||
if (Math.abs(afterReconfig.possibility - expectedDirect) > EPS) {
|
||||
fail(`after reconfig to direct: expected ${expectedDirect}, got ${afterReconfig.possibility}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { directResult, afterReconfig };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pEdge: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pMember: rigor.gen.oneOf(POSSIBILITIES),
|
||||
configOrder: rigor.gen.int(0, 1)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('config-change', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'consistency-config-change' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'config-change');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `CONFIG CHANGE violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('THRESHOLD: minPossibility excludes weaker paths', async () => {
|
||||
async function check({ strong, weak, threshold }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:alice', 'mid:1', 'mid:2', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'group' : 'doc'));
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: strong });
|
||||
arbiter.addRelation('mid:1', 'viewer', 'doc:1', { possibility: strong });
|
||||
arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: weak });
|
||||
arbiter.addRelation('mid:2', 'viewer', 'doc:1', { possibility: weak });
|
||||
|
||||
const noThreshold = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (Math.abs(noThreshold.possibility - strong) > EPS) {
|
||||
fail(`no threshold: expected ${strong}, got ${noThreshold.possibility}`);
|
||||
}
|
||||
|
||||
const filtered = arbiter.check('user:alice', 'can_access', 'doc:1', { minPossibility: threshold });
|
||||
const expected = threshold > weak ? strong : strong;
|
||||
if (Math.abs(filtered.possibility - expected) > EPS) {
|
||||
fail(`threshold ${threshold}: expected ${expected}, got ${filtered.possibility}`);
|
||||
}
|
||||
return { noThreshold, filtered };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
strong: rigor.gen.oneOf([0.75, 1]),
|
||||
weak: rigor.gen.oneOf([0.25, 0.5]),
|
||||
threshold: rigor.gen.oneOf([0, 0.4, 0.6, 0.9])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('threshold-excludes', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'consistency-threshold' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'threshold-excludes');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `THRESHOLD violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('VALUE COLLECTION COMPLETENESS: all parallel chain paths contribute their values', async () => {
|
||||
async function check({ paths, value }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
const mids = [];
|
||||
for (let i = 0; i < paths; i++) {
|
||||
const key = `mid:${i}`;
|
||||
mids.push(key);
|
||||
arbiter.addNode(key, 'account');
|
||||
}
|
||||
arbiter.setRelationConfig('can_debit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
||||
arbiter.setRelationConfig('authorized_balance', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'can_debit', direction: 'out' },
|
||||
{ relation: 'has_balance', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
// N parallel paths, each carrying the same value; the paths have
|
||||
// DIFFERENT possibilities (first strongest, then weakening) so the
|
||||
// old dedup-before-collect bug would drop the weaker paths' values.
|
||||
for (let i = 0; i < paths; i++) {
|
||||
const p = 1 - i * 0.15; // 1, 0.85, 0.7, ...
|
||||
arbiter.addRelation('user:alice', 'can_debit', mids[i], { possibility: p });
|
||||
arbiter.addRelation(mids[i], 'has_balance', 'doc:1', { possibility: p, value });
|
||||
}
|
||||
|
||||
const result = arbiter.check('user:alice', 'authorized_balance', 'doc:1', {
|
||||
collectValues: true,
|
||||
includeMeta: true
|
||||
});
|
||||
|
||||
const collected = result.collectedValues || [];
|
||||
if (collected.length !== paths) {
|
||||
fail(`expected ${paths} collected values, got ${collected.length}`);
|
||||
}
|
||||
const total = collected.reduce((sum, cv) => sum + (cv.value?.min ?? cv.value ?? 0), 0);
|
||||
const expectedTotal = paths * value;
|
||||
if (Math.abs(total - expectedTotal) > EPS) {
|
||||
fail(`value sum: expected ${expectedTotal}, got ${total} (${collected.length} values)`);
|
||||
}
|
||||
return { count: collected.length, total };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
paths: rigor.gen.int(2, 5),
|
||||
value: rigor.gen.int(10, 500)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('values-complete', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'consistency-value-completeness' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'values-complete');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `VALUE COLLECTION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('REPEATED CHECK DETERMINISM: identical checks never drift', async () => {
|
||||
async function check({ p, repeats }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:alice', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p });
|
||||
|
||||
const first = arbiter.check('user:alice', 'viewer', 'doc:1');
|
||||
for (let i = 0; i < repeats; i++) {
|
||||
const again = arbiter.check('user:alice', 'viewer', 'doc:1');
|
||||
if (again.possibility !== first.possibility) {
|
||||
fail(`check ${i + 1}: ${again.possibility} differs from first ${first.possibility}`);
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
p: rigor.gen.oneOf(POSSIBILITIES),
|
||||
repeats: rigor.gen.int(1, 10)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('deterministic', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 300, seed: 'consistency-determinism' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'deterministic');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `DETERMINISM violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* rigor/zanzibar-defeasible-dsl-comparator.test.js — js-rigor property tests
|
||||
* for defeasible logic, DSL→runtime parity, and value aggregation.
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - NEVER: an absolute denial overrides every positive rule (binary).
|
||||
* - UNLESS (defeater): a triggered defeater blocks the defeasible grant.
|
||||
* - ALWAYS (strict): strict grants survive unless NEVER fires.
|
||||
* - WHEN: a defeasible rule grants iff its conditions hold and no
|
||||
* defeater fires.
|
||||
* - DSL→RUNTIME PARITY: evidence compiled from DSL behaves identically
|
||||
* to the equivalent hand-written relation configs on identical graphs,
|
||||
* across generated edge possibilities.
|
||||
* - AGGREGATION: a relational-comparator sum over N parallel chain paths
|
||||
* totals ALL path values (regression — the dedup-before-collect bug
|
||||
* dropped weaker paths' contributions).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { rigor } from '@rigor/core';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
import { DSLCompiler } from '../../src/ast/DSLCompiler.js';
|
||||
|
||||
const EPS = 1e-9;
|
||||
const POSSIBILITIES = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe('Defeasible logic, DSL parity, aggregation (rigor)', () => {
|
||||
it('DEFEASIBLE: NEVER denies, UNLESS defeats, ALWAYS survives, WHEN grants', async () => {
|
||||
async function check({ shape, pAllow, pBlock }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:u', 'doc:1'].forEach((k) =>
|
||||
arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
|
||||
arbiter.setRelationConfig('can_access', { type: 'direct' });
|
||||
arbiter.setRelationConfig('is_blocked', { type: 'direct' });
|
||||
arbiter.setRelationConfig('is_emergency', { type: 'direct' });
|
||||
|
||||
const configs = {
|
||||
when: {
|
||||
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
|
||||
},
|
||||
unless: {
|
||||
when: { intersection: [{ type: 'direct', relation: 'can_access' }] },
|
||||
unless: { union: [{ type: 'direct', relation: 'is_blocked' }] }
|
||||
},
|
||||
never: {
|
||||
never: { union: [{ type: 'direct', relation: 'is_blocked' }] },
|
||||
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
|
||||
},
|
||||
always: {
|
||||
always: { type: 'direct', relation: 'is_emergency' },
|
||||
when: { intersection: [{ type: 'direct', relation: 'can_access' }] }
|
||||
}
|
||||
};
|
||||
arbiter.setRelationConfig('viewer', configs[shape]);
|
||||
|
||||
arbiter.addRelation('user:u', 'can_access', 'doc:1', { possibility: pAllow });
|
||||
if (shape !== 'always') {
|
||||
arbiter.addRelation('user:u', 'is_blocked', 'doc:1', { possibility: pBlock });
|
||||
} else {
|
||||
arbiter.addRelation('user:u', 'is_emergency', 'doc:1', { possibility: pBlock });
|
||||
}
|
||||
|
||||
const result = arbiter.check('user:u', 'viewer', 'doc:1');
|
||||
// Normal-mode defeasible semantics (continuous possibility):
|
||||
// - when: base = when-part possibility
|
||||
// - unless: result *= (1 - defeater possibility)
|
||||
// - never: result = 0 when never possibility >= 0.5
|
||||
// - always: result = max(base, strict possibility)
|
||||
let expected;
|
||||
if (shape === 'when') {
|
||||
expected = pAllow;
|
||||
} else if (shape === 'unless') {
|
||||
expected = pAllow * (1 - pBlock);
|
||||
} else if (shape === 'never') {
|
||||
expected = pBlock >= 0.5 ? 0 : pAllow;
|
||||
} else {
|
||||
expected = Math.max(pAllow, pBlock);
|
||||
}
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`${shape}: expected ${expected}, got ${result.possibility} (pAllow=${pAllow}, pBlock=${pBlock})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
shape: rigor.gen.oneOf(['when', 'unless', 'never', 'always']),
|
||||
pAllow: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pBlock: rigor.gen.oneOf(POSSIBILITIES)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('defeasible-semantics', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 600, seed: 'defeasible-semantics' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'defeasible-semantics');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `DEFEASIBLE semantics violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('DSL→RUNTIME PARITY: compiled evidence matches hand-written configs on identical graphs', async () => {
|
||||
const DSL = `
|
||||
definition Doc { id: string }
|
||||
definition Dept { id: string }
|
||||
fact owns(user: User, doc: Doc)
|
||||
fact works_in(user: User, dept: Dept)
|
||||
fact has_access(dept: Dept, doc: Doc)
|
||||
evidence can_read(user: User, doc: Doc) { owns(user, doc) }
|
||||
evidence can_access(user: User, doc: Doc) { works_in(user, *d) { has_access(d, doc) } }
|
||||
`;
|
||||
|
||||
async function check({ pOwn, pMember, pReads }) {
|
||||
// Compiled arbiter: DSL -> generated configs
|
||||
const compiled = new Arbiter();
|
||||
compiled.addNode('user:alice', 'user');
|
||||
compiled.addNode('group:eng', 'group');
|
||||
compiled.addNode('doc:1', 'doc');
|
||||
const compiler = new DSLCompiler(compiled);
|
||||
const result = compiler.compile(DSL, 'parity');
|
||||
if (!result.success) {
|
||||
fail(`DSL compile failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
|
||||
// Hand-written arbiter: equivalent configs by hand
|
||||
const manual = new Arbiter();
|
||||
manual.addNode('user:alice', 'user');
|
||||
manual.addNode('group:eng', 'group');
|
||||
manual.addNode('doc:1', 'doc');
|
||||
manual.setRelationConfig('can_read', { type: 'direct', relation: 'owns' });
|
||||
manual.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'works_in', direction: 'out' },
|
||||
{ relation: 'has_access', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
// Identical graph on both
|
||||
for (const arb of [compiled, manual]) {
|
||||
arb.addRelation('user:alice', 'owns', 'doc:1', { possibility: pOwn });
|
||||
arb.addRelation('user:alice', 'works_in', 'group:eng', { possibility: pMember });
|
||||
arb.addRelation('group:eng', 'has_access', 'doc:1', { possibility: pReads });
|
||||
}
|
||||
|
||||
for (const rel of ['can_read', 'can_access']) {
|
||||
const compiledResult = compiled.check('user:alice', rel, 'doc:1');
|
||||
const manualResult = manual.check('user:alice', rel, 'doc:1');
|
||||
if (Math.abs(compiledResult.possibility - manualResult.possibility) > EPS) {
|
||||
fail(`${rel}: compiled=${compiledResult.possibility} vs manual=${manualResult.possibility}`);
|
||||
}
|
||||
}
|
||||
return { canRead: compiled.check('user:alice', 'can_read', 'doc:1').possibility };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pOwn: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pMember: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pReads: rigor.gen.oneOf(POSSIBILITIES)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('dsl-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'dsl-runtime-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'dsl-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `DSL→RUNTIME parity violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('AGGREGATION: relational-comparator sum totals ALL parallel path values', async () => {
|
||||
async function check({ paths, value, price }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('feature:premium', 'feature');
|
||||
const mids = [];
|
||||
for (let i = 0; i < paths; i++) {
|
||||
const key = `mid:${i}`;
|
||||
mids.push(key);
|
||||
arbiter.addNode(key, 'account');
|
||||
}
|
||||
arbiter.setRelationConfig('can_debit', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_price', { type: 'direct' });
|
||||
arbiter.setRelationConfig('authorized_balance_check', {
|
||||
type: 'relational_comparator',
|
||||
left: {
|
||||
rule: {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'can_debit', direction: 'out' },
|
||||
{ relation: 'has_balance', direction: 'out' }
|
||||
],
|
||||
extractValues: true,
|
||||
extractFrom: 1,
|
||||
extractRelation: 'has_balance',
|
||||
valueAggregation: 'sum',
|
||||
evaluateFrom: 'user'
|
||||
},
|
||||
extractValue: true,
|
||||
aggregator: 'sum',
|
||||
evaluateFrom: 'user',
|
||||
decayRate: 0,
|
||||
decayFunction: 'rational'
|
||||
},
|
||||
right: {
|
||||
rule: { type: 'direct', relation: 'has_price', evaluateFrom: 'object' },
|
||||
extractValue: true,
|
||||
evaluateFrom: 'object',
|
||||
decayRate: 0,
|
||||
decayFunction: 'rational'
|
||||
},
|
||||
comparator: '>=',
|
||||
fallbackBehavior: 'deny'
|
||||
});
|
||||
|
||||
// N parallel paths, weakening possibilities (the old dedup bug dropped
|
||||
// the weaker paths' values, under-reporting the authorized total).
|
||||
for (let i = 0; i < paths; i++) {
|
||||
const p = 1 - i * 0.15;
|
||||
arbiter.addRelation('user:alice', 'can_debit', mids[i], { possibility: p });
|
||||
arbiter.addRelation(mids[i], 'has_balance', 'feature:premium', { possibility: p, value });
|
||||
}
|
||||
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: price });
|
||||
|
||||
const result = arbiter.check('user:alice', 'authorized_balance_check', 'feature:premium', {
|
||||
includeMeta: true
|
||||
});
|
||||
|
||||
const expectedTotal = paths * value;
|
||||
const leftValue = result.meta?.allow?.leftValue ?? result.meta?.deny?.leftValue;
|
||||
if (Math.abs(leftValue - expectedTotal) > EPS) {
|
||||
fail(`sum: expected ${expectedTotal}, got ${leftValue} (${paths} paths × ${value})`);
|
||||
}
|
||||
const expectedGrant = expectedTotal >= price;
|
||||
if ((result.possibility > 0) !== expectedGrant) {
|
||||
fail(`decision: expected grant=${expectedGrant} (${expectedTotal} >= ${price}), got ${result.possibility}`);
|
||||
}
|
||||
return { leftValue, granted: result.possibility > 0 };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
paths: rigor.gen.int(2, 5),
|
||||
value: rigor.gen.int(50, 300),
|
||||
price: rigor.gen.int(100, 1000)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('aggregation-complete', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'aggregation-completeness' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'aggregation-complete');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `AGGREGATION violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* rigor/zanzibar-semantics.test.js — js-rigor property tests for
|
||||
* Zanzibar-style authorization graph semantics.
|
||||
*
|
||||
* Zanzibar's core model (Google's global authorization system):
|
||||
* relation tuples (object, relation, user), usersets, and rewrite rules
|
||||
* (direct, tuple-to-userset, union, intersection, exclusion).
|
||||
*
|
||||
* Properties verified:
|
||||
*
|
||||
* - UNION: a grant from ANY child grants at the child's possibility
|
||||
* (max over children); no child grants → deny.
|
||||
* - INTERSECTION: ALL children must grant; result is the min.
|
||||
* - EXCLUSION (A AND NOT B): the denied set blocks the allowed set;
|
||||
* p = pA * (1 - pB).
|
||||
* - EXPAND PARITY (flagship): a brute-force reference implementation of
|
||||
* Zanzibar's Expand RPC (userset expansion) is compared against
|
||||
* arbiter.check() over RANDOM graphs with random rewrite configs —
|
||||
* direct, chain (transitive member_of), tuple-to-userset, union and
|
||||
* exclusion — with random edge possibilities including 0.
|
||||
* - NESTED CHAIN: membership transitivity through N nested groups honors
|
||||
* the weakest link (min possibility along the full path).
|
||||
* - CYCLE SAFETY: cyclic membership graphs terminate and never create
|
||||
* spurious access.
|
||||
*/
|
||||
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 POSSIBILITIES = [0, 0.25, 0.5, 0.75, 1];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference implementation of Zanzibar's Expand RPC over a graph of
|
||||
* users / groups / one object, for the rewrite configs the engine supports.
|
||||
* Returns the possibility that `user` is in the userset of (object, rel).
|
||||
*
|
||||
* Semantics mirrored from the engine (verified empirically):
|
||||
* - direct: only edges whose subject is the user itself (no implicit
|
||||
* group expansion on direct tuples); possibility = edge possibility.
|
||||
* - chain: transitive `member_of` traversal (min along path, max over
|
||||
* paths), then a final edge of the terminal relation into the object.
|
||||
* - tuple_to_userset: object's tuplesetRelation edges (doc → group),
|
||||
* joined with a DIRECT computedRelation edge from the user; min.
|
||||
* - union: max over children. intersection: min over children.
|
||||
* - exclusion: pA * (1 - pB).
|
||||
*/
|
||||
export function buildExpandOracle(graph, config) {
|
||||
const { memberOf, edgesByRelation } = graph;
|
||||
const edges = (rel) => edgesByRelation.get(rel) || [];
|
||||
|
||||
function directUserset(rel) {
|
||||
return (userId) => {
|
||||
let best = 0;
|
||||
// direct edges point FROM the subject TO the object (src=subject, dst=object)
|
||||
for (const e of edges(rel)) {
|
||||
if (e.from === userId && e.subject === graph.objectId) {
|
||||
if (e.p > best) best = e.p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
}
|
||||
|
||||
function transitiveMembers(userId) {
|
||||
// BFS over member_of edges; min possibility along each path; max over paths.
|
||||
const best = new Map([[userId, 1]]);
|
||||
const queue = [userId];
|
||||
while (queue.length) {
|
||||
const node = queue.shift();
|
||||
const nodeP = best.get(node);
|
||||
for (const e of edges('member_of')) {
|
||||
if (e.from !== node) continue;
|
||||
const nextP = Math.min(nodeP, e.p);
|
||||
const prev = best.get(e.subject);
|
||||
if (prev === undefined || nextP > prev) {
|
||||
best.set(e.subject, nextP);
|
||||
queue.push(e.subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function reachAtExactHops(userId, hops) {
|
||||
// Nodes reachable in EXACTLY `hops` member_of traversals, with the
|
||||
// best (max) possibility per node. Mirrors the engine's per-step
|
||||
// path extension (paths always traverse every step).
|
||||
let frontier = new Map([[userId, 1]]);
|
||||
for (let h = 0; h < hops; h++) {
|
||||
const next = new Map();
|
||||
for (const [node, nodeP] of frontier) {
|
||||
for (const e of edges('member_of')) {
|
||||
if (e.from !== node) continue;
|
||||
const nextP = Math.min(nodeP, e.p);
|
||||
const prev = next.get(e.subject);
|
||||
if (prev === undefined || nextP > prev) {
|
||||
next.set(e.subject, nextP);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) break;
|
||||
}
|
||||
return frontier;
|
||||
}
|
||||
|
||||
function evalRule(rule, userId) {
|
||||
if (rule.type === 'direct') {
|
||||
return directUserset(rule.relation)(userId);
|
||||
}
|
||||
if (rule.type === 'chain') {
|
||||
// steps: N member_of hops then a terminal relation into the object.
|
||||
// The engine extends paths through EVERY step, so the terminal edge
|
||||
// may only originate from nodes reachable in exactly N member_of hops
|
||||
// (the starting user alone never satisfies the chain).
|
||||
const steps = rule.steps;
|
||||
const terminal = steps[steps.length - 1].relation;
|
||||
const memberHops = steps.length - 1;
|
||||
let best = 0;
|
||||
if (memberHops > 0) {
|
||||
for (const [node, p] of reachAtExactHops(userId, memberHops)) {
|
||||
for (const e of edges(terminal)) {
|
||||
if (e.from === node && e.subject === graph.objectId) {
|
||||
const pathP = Math.min(p, e.p);
|
||||
if (pathP > best) best = pathP;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
if (rule.type === 'tuple_to_userset') {
|
||||
let best = 0;
|
||||
for (const t of edges(rule.tuplesetRelation)) {
|
||||
if (t.from !== graph.objectId) continue;
|
||||
// computed side is a DIRECT relation lookup (verified semantics)
|
||||
let memberP = 0;
|
||||
for (const m of edges(rule.computedRelation)) {
|
||||
if (m.from === userId && m.subject === t.subject) {
|
||||
if (m.p > memberP) memberP = m.p;
|
||||
}
|
||||
}
|
||||
const combined = Math.min(t.p, memberP);
|
||||
if (combined > best) best = combined;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
if (rule.union) {
|
||||
return Math.max(0, ...rule.union.map((r) => evalRule(r, userId)));
|
||||
}
|
||||
if (rule.intersection) {
|
||||
return Math.min(...rule.intersection.map((r) => evalRule(r, userId)));
|
||||
}
|
||||
if (rule.exclusion) {
|
||||
const [a, b] = rule.exclusion;
|
||||
return evalRule(a, userId) * (1 - evalRule(b, userId));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return { evalRule };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a random Zanzibar-style graph + rewrite config, and the matching
|
||||
* Arbiter instance. Config shapes are chosen from a fixed family so the
|
||||
* oracle and the engine stay in sync by construction.
|
||||
*/
|
||||
export function buildRandomZanzibarCase(seedCase) {
|
||||
const { userCount, groupCount, configKind } = seedCase;
|
||||
const users = Array.from({ length: userCount }, (_, i) => `user:${i}`);
|
||||
const groups = Array.from({ length: groupCount }, (_, i) => `group:${i}`);
|
||||
const allSubjects = [...users, ...groups];
|
||||
const doc = 'doc:1';
|
||||
|
||||
const arbiter = new Arbiter();
|
||||
for (const u of users) arbiter.addNode(u, 'user');
|
||||
for (const g of groups) arbiter.addNode(g, 'group');
|
||||
arbiter.addNode(doc, 'doc');
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
|
||||
const edgesByRelation = new Map();
|
||||
const addEdge = (rel, from, subject, p) => {
|
||||
let list = edgesByRelation.get(rel);
|
||||
if (!list) {
|
||||
list = [];
|
||||
edgesByRelation.set(rel, list);
|
||||
}
|
||||
// The engine dedups relations per (src, rel, dst) with last-write-wins
|
||||
// (addRelation updates an existing tuple). Mirror that so the oracle
|
||||
// and the engine observe identical edge data.
|
||||
const existing = list.find((e) => e.from === from && e.subject === subject);
|
||||
if (existing) {
|
||||
existing.p = p;
|
||||
arbiter.removeRelation(from, rel, subject);
|
||||
}
|
||||
list.push(existing || { from, subject, p });
|
||||
arbiter.addRelation(from, rel, subject, { possibility: p });
|
||||
};
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// member_of edges: users AND groups can be members (nesting), subjects can be groups
|
||||
const memberEdges = Math.max(1, Math.floor((userCount + groupCount) / 2));
|
||||
for (let i = 0; i < memberEdges; i++) {
|
||||
const from = pick(allSubjects);
|
||||
const subject = pick(groups);
|
||||
addEdge('member_of', from, subject, pick(POSSIBILITIES));
|
||||
}
|
||||
|
||||
// Two direct relations (for union / exclusion / direct configs)
|
||||
const d1 = 'viewer_allowed';
|
||||
const d2 = 'viewer_staff';
|
||||
const denied = 'viewer_denied';
|
||||
for (const rel of [d1, d2, denied]) {
|
||||
arbiter.setRelationConfig(rel, { type: 'direct' });
|
||||
}
|
||||
const directEdgeCount = Math.max(1, Math.floor(userCount / 2) + 1);
|
||||
for (let i = 0; i < directEdgeCount; i++) {
|
||||
// direct edges point FROM the subject TO the object (src=subject, dst=doc)
|
||||
addEdge(pick([d1, d2]), pick(allSubjects), doc, pick(POSSIBILITIES));
|
||||
addEdge(denied, pick(allSubjects), doc, pick(POSSIBILITIES));
|
||||
}
|
||||
|
||||
// Tupleset relation: doc → group (for tuple_to_userset)
|
||||
const t1 = 'owner';
|
||||
arbiter.setRelationConfig(t1, { type: 'direct' });
|
||||
if (groupCount > 0) {
|
||||
for (let i = 0; i < groupCount; i++) {
|
||||
addEdge(t1, doc, pick(groups), pick(POSSIBILITIES));
|
||||
}
|
||||
}
|
||||
|
||||
const directRule = () => ({ type: 'direct', relation: pick([d1, d2]) });
|
||||
const chainRule = () => ({
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: pick([d1, d2]), direction: 'out' }
|
||||
]
|
||||
});
|
||||
const ttuRule = () => ({
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: t1,
|
||||
computedRelation: 'member_of',
|
||||
reverse: false
|
||||
});
|
||||
const allowRule = () => pick([directRule(), chainRule(), ttuRule()]);
|
||||
|
||||
let config;
|
||||
if (configKind === 0) config = allowRule();
|
||||
else if (configKind === 1) config = { union: [allowRule(), allowRule()] };
|
||||
else if (configKind === 2) config = { intersection: [allowRule(), allowRule()] };
|
||||
else if (configKind === 3) config = { exclusion: [allowRule(), { type: 'direct', relation: denied }] };
|
||||
else config = { union: [allowRule(), { exclusion: [allowRule(), { type: 'direct', relation: denied }] }] };
|
||||
|
||||
arbiter.setRelationConfig('viewer', config);
|
||||
return {
|
||||
arbiter,
|
||||
graph: {
|
||||
objectId: doc,
|
||||
memberOf: edgesByRelation.get('member_of') || [],
|
||||
edgesByRelation
|
||||
},
|
||||
config,
|
||||
users
|
||||
};
|
||||
}
|
||||
|
||||
describe('Zanzibar rewrite-rule semantics (rigor)', () => {
|
||||
it('UNION: max over children — any grant grants, none denies', async () => {
|
||||
async function check({ pa, pb, hasA, hasB }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
|
||||
arbiter.setRelationConfig('rel_a', { type: 'direct' });
|
||||
arbiter.setRelationConfig('rel_b', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', {
|
||||
union: [
|
||||
{ type: 'direct', relation: 'rel_a' },
|
||||
{ type: 'direct', relation: 'rel_b' }
|
||||
]
|
||||
});
|
||||
if (hasA) arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
|
||||
if (hasB) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });
|
||||
|
||||
const result = arbiter.check('user:u', 'viewer', 'doc:1');
|
||||
const expected = Math.max(hasA ? pa : 0, hasB ? pb : 0);
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`union: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pa: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pb: rigor.gen.oneOf(POSSIBILITIES),
|
||||
hasA: rigor.gen.boolean(),
|
||||
hasB: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('union-max', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'zanzibar-union' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'union-max');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `UNION contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('INTERSECTION: min over children — all must grant', async () => {
|
||||
async function check({ pa, pb, hasA, hasB }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
|
||||
arbiter.setRelationConfig('rel_a', { type: 'direct' });
|
||||
arbiter.setRelationConfig('rel_b', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', {
|
||||
intersection: [
|
||||
{ type: 'direct', relation: 'rel_a' },
|
||||
{ type: 'direct', relation: 'rel_b' }
|
||||
]
|
||||
});
|
||||
if (hasA) arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
|
||||
if (hasB) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });
|
||||
|
||||
const result = arbiter.check('user:u', 'viewer', 'doc:1');
|
||||
const expected = Math.min(hasA ? pa : 0, hasB ? pb : 0);
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`intersection: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pa: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pb: rigor.gen.oneOf(POSSIBILITIES),
|
||||
hasA: rigor.gen.boolean(),
|
||||
hasB: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('intersection-min', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'zanzibar-intersection' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'intersection-min');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `INTERSECTION contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('EXCLUSION (A AND NOT B): denied set blocks the allowed set', async () => {
|
||||
async function check({ pa, pb, denied }) {
|
||||
const arbiter = new Arbiter();
|
||||
['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
|
||||
arbiter.setRelationConfig('rel_a', { type: 'direct' });
|
||||
arbiter.setRelationConfig('rel_b', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', {
|
||||
exclusion: [
|
||||
{ type: 'direct', relation: 'rel_a' },
|
||||
{ type: 'direct', relation: 'rel_b' }
|
||||
]
|
||||
});
|
||||
arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
|
||||
if (denied) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });
|
||||
|
||||
const result = arbiter.check('user:u', 'viewer', 'doc:1');
|
||||
const expected = pa * (1 - (denied ? pb : 0));
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`exclusion: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
pa: rigor.gen.oneOf(POSSIBILITIES),
|
||||
pb: rigor.gen.oneOf(POSSIBILITIES),
|
||||
denied: rigor.gen.boolean()
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('exclusion-blocks', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'zanzibar-exclusion' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'exclusion-blocks');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `EXCLUSION contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('EXPAND PARITY: check() agrees with the Expand-RPC oracle on random graphs', async () => {
|
||||
async function check(caseParams) {
|
||||
const { arbiter, graph, config, users } = buildRandomZanzibarCase(caseParams);
|
||||
const oracle = buildExpandOracle(graph, config);
|
||||
|
||||
for (const user of users) {
|
||||
const expected = oracle.evalRule(config, user);
|
||||
const actual = arbiter.check(user, 'viewer', graph.objectId);
|
||||
if (Math.abs(actual.possibility - expected) > EPS) {
|
||||
fail(`expand parity for ${user}: oracle=${expected}, engine=${actual.possibility}`);
|
||||
}
|
||||
}
|
||||
return { checked: users.length };
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
userCount: rigor.gen.int(1, 4),
|
||||
groupCount: rigor.gen.int(0, 4),
|
||||
configKind: rigor.gen.int(0, 4)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('expand-parity', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 800, seed: 'zanzibar-expand-parity' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'expand-parity');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `EXPAND PARITY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('NESTED CHAIN: transitivity through N groups honors the weakest link', async () => {
|
||||
async function check({ depth, ps }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
const groupKeys = [];
|
||||
for (let i = 0; i < depth; i++) {
|
||||
const key = `group:${i}`;
|
||||
groupKeys.push(key);
|
||||
arbiter.addNode(key, 'group');
|
||||
}
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
const steps = [];
|
||||
for (let i = 0; i < depth; i++) {
|
||||
steps.push({ relation: 'member_of', direction: 'out' });
|
||||
}
|
||||
steps.push({ relation: 'viewer', direction: 'out' });
|
||||
arbiter.setRelationConfig('can_access', { type: 'chain', steps });
|
||||
|
||||
let from = 'user:alice';
|
||||
let expected = 1;
|
||||
for (let i = 0; i < depth; i++) {
|
||||
arbiter.addRelation(from, 'member_of', groupKeys[i], { possibility: ps[i] });
|
||||
expected = Math.min(expected, ps[i]);
|
||||
from = groupKeys[i];
|
||||
}
|
||||
arbiter.addRelation(from, 'viewer', 'doc:1', { possibility: ps[depth] });
|
||||
expected = Math.min(expected, ps[depth]);
|
||||
|
||||
const result = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (Math.abs(result.possibility - expected) > EPS) {
|
||||
fail(`nested chain depth ${depth}: expected ${expected}, got ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
depth: rigor.gen.int(1, 5),
|
||||
ps: rigor.gen.array(rigor.gen.oneOf(POSSIBILITIES), 2, 6)
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('nested-weakest-link', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 500, seed: 'zanzibar-nested-chain' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'nested-weakest-link');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `NESTED CHAIN contract violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
|
||||
it('CYCLE SAFETY: cyclic memberships terminate and never fabricate access', async () => {
|
||||
async function check({ cycleSize, memberP }) {
|
||||
const arbiter = new Arbiter();
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
const groups = [];
|
||||
for (let i = 0; i < cycleSize; i++) {
|
||||
const key = `group:${i}`;
|
||||
groups.push(key);
|
||||
arbiter.addNode(key, 'group');
|
||||
}
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('viewer', { type: 'direct' });
|
||||
arbiter.setRelationConfig('can_access', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'member_of', direction: 'out' },
|
||||
{ relation: 'viewer', direction: 'out' }
|
||||
]
|
||||
});
|
||||
|
||||
// Closed cycle: g0 → g1 → ... → g0 (no user attached, no doc access)
|
||||
for (let i = 0; i < cycleSize; i++) {
|
||||
const next = groups[(i + 1) % cycleSize];
|
||||
arbiter.addRelation(groups[i], 'member_of', next, { possibility: memberP });
|
||||
}
|
||||
// A user attached to the cycle must not gain access to the doc
|
||||
arbiter.addRelation('user:alice', 'member_of', groups[0], { possibility: memberP });
|
||||
|
||||
const result = arbiter.check('user:alice', 'can_access', 'doc:1');
|
||||
if (result.possibility !== 0) {
|
||||
fail(`cycle fabricated access: ${result.possibility}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = await rigor.campaign(
|
||||
[
|
||||
rigor.fn('check', check, rigor.args(
|
||||
rigor.gen.object({
|
||||
cycleSize: rigor.gen.int(2, 6),
|
||||
memberP: rigor.gen.oneOf([0.25, 0.5, 1])
|
||||
})
|
||||
))
|
||||
],
|
||||
rigor.crucible([
|
||||
rigor.invariant('cycle-safe', ({ error, errorMessage }) => !error && !errorMessage)
|
||||
])
|
||||
).run({ effort: 400, seed: 'zanzibar-cycle-safety' });
|
||||
|
||||
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'cycle-safe');
|
||||
assert.ok(inv);
|
||||
assert.equal(inv.passed, true, `CYCLE SAFETY violated in ${inv.failureCount} cases`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user