Files
core/tests/rigor/challenge-rule.test.js
John Dvorak ed34df4474
CI / benchmark (push) Successful in 48s
CI / test (push) Successful in 5m26s
CI / publish (push) Has been skipped
feat: intermediate chain condition steps, graph-version cache invalidation, rolling-hash chain keys; fix vacuous rigor invariants
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.
2026-08-03 13:26:42 -07:00

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