ed34df4474
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
position is now EXPANDED from the current node — the rule's base edges'
destinations, filtered by its defeaters/requirements — and traversal
continues from each discovered node. Adds _expandRuleFromSrc / direct /
logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
entity IS the object parameter (trusted(other) inside peer_trusted(user,
other)); previously only subject-var unary calls (_subjectAsObject) were
handled, so object-var unary defeaters never fired.
Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
DecisionCache rule cache now stamp entries with the graph version and treat
any mismatch as a miss — graph mutations can no longer serve stale
chain/authorization results.
Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
JSON.stringify — no string allocation or serialization on the chain-cache
hot path. Composite keys stay structured strings because the direct-check
cache pattern-invalidates by relation ID.
Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
!error && !errorMessage) never saw fn throws — vacuous. Migrated to
({ actual }) => actual !== undefined, which fails on any thrown violation
while passing legitimate null-skips. The migration immediately surfaced
two latent bugs, now fixed:
* node-manager/graph-indices skip paths returned bare undefined (falsy
sentinel) — return { skipped: true }.
* complex-graph-values-crucible expiry section rewrote values equal to the
mutation loop's last write; the engine (by design) keeps the old
timestamp on same-value rewrites so the pre-expiry grant never
materialized. Now writes guaranteed-different values.
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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-subject-key-wins', 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-subject-mapping', 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-within-units', 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-within-priority', 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-null-when-absent', 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', ({ actual }) => actual !== undefined)
|
|
])
|
|
).run({ seed: 'challenge-rule-build-requirement', 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`);
|
|
});
|
|
});
|