Files
core/tests/rigor/direct-rule.test.js
T

460 lines
20 KiB
JavaScript
Raw Normal View History

/**
* 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-no-relation', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-strength', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-reverse', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-fast-path', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-collect-off', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-collect-on', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-precedence', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'direct-rule-shape', effort: 800 , artifacts: { dir: '', persist: 'never' }});
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`);
});
});