4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
/**
|
|
* 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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
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`);
|
|
});
|
|
});
|