Files
core/tests/rigor/zanzibar-consistency.test.js
T

387 lines
16 KiB
JavaScript
Raw Normal View History

/**
* 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`);
});
});