Files
core/tests/rigor/challenge-proof.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

297 lines
11 KiB
JavaScript

/**
* rigor/challenge-proof.test.js — js-rigor property tests for
* PartialGraphContext.getChallengeProof.
*
* rigor.fn receives args positionally, not as a single destructured object.
* getChallengeProof is a deterministic pure function on a Map of challenge
* records; perfect target for property-based testing.
* Properties verified:
* - skipped: proof with expiresAt <= now is never returned
* - skipped: proof with (now - issuedAt) > withinMs is never returned
* - pick: returned proof has the largest issuedAt among passes
* - null: when no proof passes filters, returns null
* - passthrough: when withinMs is null/undefined, time filter is off
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';
const CHALLENGE_NAMES = ['mfa', 'captcha', 'webauthn', 'password'];
const SUBJECTS = ['user:abc', 'user:def', 'user:ghi', 'user:jkl'];
/**
* Brute-force oracle: re-implements getChallengeProof in 6 lines.
* Used as the oracle in the campaign — if it ever disagrees with the
* production code, that's a bug.
*/
function bruteForceOracle(proofs, name, subjectId, withinMs, now) {
const matching = proofs.filter(p =>
p.name === name && p.subject === subjectId
);
let best = null;
for (const proof of matching) {
if (proof.expiresAt != null && proof.expiresAt <= now) continue;
if (withinMs != null && now - proof.issuedAt > withinMs) continue;
if (!best || proof.issuedAt > best.issuedAt) best = proof;
}
return best;
}
/**
* Build a fresh PartialGraphContext for the given subject + proofs.
* PartialGraphContext._resolveNodeId reads `arbiter.nodeIdByKey` to
* share IDs with the parent graph. Pass a stub arbiter so the lookup
* doesn't crash when the campaign shrinks inputs down to edge cases.
*/
function buildContext({ subjectIds, proofs }) {
const stubArbiter = { nodeIdByKey: new Map() };
const context = new PartialGraphContext(stubArbiter, { skipContext: false });
for (const id of subjectIds) {
context._resolveNodeId(id);
}
for (const proof of proofs) {
context._addChallengeProof(proof);
}
return context;
}
describe('PartialGraphContext.getChallengeProof (rigor)', () => {
it('reference oracle matches production across fuzzed inputs', async () => {
// Args are positional: (proofs, name, subject, withinMs, now).
async function referenceCheck(proofs, name, subject, withinMs, now) {
const ctx = buildContext({ subjectIds: [subject], proofs });
const subjectId = ctx._resolveNodeId(subject);
const actual = ctx.getChallengeProof(name, subjectId, withinMs, now);
const expected = bruteForceOracle(proofs, name, subject, withinMs, now);
if (expected === null) {
if (actual !== null) {
throw new Error(
`expected null but got proof with issuedAt=${actual.issuedAt}`
);
}
return null;
}
if (actual === null) {
throw new Error(
`expected proof with issuedAt=${expected.issuedAt} but got null`
);
}
if (actual.issuedAt !== expected.issuedAt) {
throw new Error(
`wrong proof returned: expected issuedAt=${expected.issuedAt}, got ${actual.issuedAt}`
);
}
return actual;
}
const report = await rigor.campaign(
[
rigor.fn('check', referenceCheck,
rigor.args(
rigor.gen.array(
rigor.gen.object({
name: rigor.gen.enum(CHALLENGE_NAMES),
subject: rigor.gen.enum(SUBJECTS),
issuedAt: rigor.gen.int(0, 100000),
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
}),
0, 5
),
rigor.gen.enum(CHALLENGE_NAMES),
rigor.gen.enum(SUBJECTS),
rigor.gen.option(rigor.gen.int(0, 100000)),
rigor.gen.int(0, 200000)
)
)
],
rigor.crucible([
rigor.invariant(
'oracle-matches-brute-force',
({ actual }) => actual !== undefined
)
])
).run({ seed: 'challenge-proof-oracle', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') {
console.log('TAP:', report.toTAP());
}
const oracle = report.crucibleVerdict?.invariants?.find(
i => i.name === 'oracle-matches-brute-force'
);
assert.ok(oracle, 'oracle invariant reported');
assert.equal(oracle.passed, true,
`getChallengeProof disagrees with brute-force oracle: ${oracle.failureCount} failures`);
});
it('never returns an expired proof (expiresAt <= now)', async () => {
async function expiredCheck(proof, now) {
const ctx = buildContext({ subjectIds: [proof.subject], proofs: [proof] });
const subjectId = ctx._resolveNodeId(proof.subject);
const result = ctx.getChallengeProof(proof.name, subjectId, null, now);
if (result && result.expiresAt != null && result.expiresAt <= now) {
throw new Error(`returned expired proof: expiresAt=${result.expiresAt}, now=${now}`);
}
return result;
}
const report = await rigor.campaign(
[
rigor.fn('expired', expiredCheck,
rigor.args(
rigor.gen.object({
name: rigor.gen.enum(CHALLENGE_NAMES),
subject: rigor.gen.enum(SUBJECTS),
issuedAt: rigor.gen.int(0, 50000),
expiresAt: rigor.gen.int(0, 100000)
}),
rigor.gen.int(50001, 200000) // now is always after the issuedAt range
)
)
],
rigor.crucible([
rigor.invariant('no-expired', ({ actual }) => actual !== undefined)
])
).run({ seed: 'challenge-proof-expired', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') {
console.log('TAP:', report.toTAP());
}
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-expired');
assert.ok(inv);
assert.equal(inv.passed, true,
`getChallengeProof returned an expired proof in ${inv.failureCount} cases`);
});
it('FIXED: zero timestamps are valid, not sentinels', () => {
const ctx = buildContext({ subjectIds: ['user:abc'], proofs: [
{ name: 'mfa', subject: 'user:abc', issuedAt: 42802, expiresAt: null },
{ name: 'mfa', subject: 'user:abc', issuedAt: 0, expiresAt: null }
] });
const subjectId = ctx._resolveNodeId('user:abc');
// issuedAt: 0 must not be replaced with the wall clock
const best = ctx.getChallengeProof('mfa', subjectId, null, 167844);
assert.equal(best.issuedAt, 42802, 'epoch-issued proof must not win via wall-clock stamping');
// expiresAt: 0 is an already-expired proof, not "no expiry"
const ctx2 = buildContext({ subjectIds: ['user:abc'], proofs: [
{ name: 'mfa', subject: 'user:abc', issuedAt: 100, expiresAt: 0 }
] });
const best2 = ctx2.getChallengeProof('mfa', subjectId, null, 167844);
assert.equal(best2, null, 'epoch-expired proof must stay expired');
});
it('returns the most-recently-issued proof when withinMs=null', async () => {
async function recentCheck(proofs, name, subject, now) {
const ctx = buildContext({ subjectIds: [subject], proofs });
const subjectId = ctx._resolveNodeId(subject);
const result = ctx.getChallengeProof(name, subjectId, null, now);
const nonExpired = proofs.filter(p =>
p.name === name && p.subject === subject &&
(p.expiresAt == null || p.expiresAt > now)
);
if (nonExpired.length === 0) {
if (result !== null) throw new Error('expected null, got result');
return null;
}
let maxIssued = nonExpired[0].issuedAt;
for (const p of nonExpired) {
if (p.issuedAt > maxIssued) maxIssued = p.issuedAt;
}
if (!result || result.issuedAt !== maxIssued) {
throw new Error(
`expected issuedAt=${maxIssued}, got ${result ? result.issuedAt : 'null'}`
);
}
return result;
}
const report = await rigor.campaign(
[
rigor.fn('recent', recentCheck,
rigor.args(
rigor.gen.array(
rigor.gen.object({
name: rigor.gen.constant('mfa'),
subject: rigor.gen.constant('user:abc'),
issuedAt: rigor.gen.int(0, 100000),
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
}),
1, 5
),
rigor.gen.constant('mfa'),
rigor.gen.constant('user:abc'),
rigor.gen.int(0, 200000)
)
)
],
rigor.crucible([
rigor.invariant('most-recent', ({ actual }) => actual !== undefined)
])
).run({ seed: 'challenge-proof-most-recent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') {
console.log('TAP:', report.toTAP());
}
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'most-recent');
assert.ok(inv);
assert.equal(inv.passed, true,
`getChallengeProof did not return the most-recent proof in ${inv.failureCount} cases: ` +
JSON.stringify((report.failures || []).slice(0, 2).map(f => ({ name: f.name, msg: f.message, seq: (f.sequence || []).map(s => s.args) }))));
});
it('enforces withinMs freshness window', async () => {
async function withinCheck(proof, withinMs, now) {
const ctx = buildContext({
subjectIds: [proof.subject],
proofs: [proof]
});
const subjectId = ctx._resolveNodeId(proof.subject);
const result = ctx.getChallengeProof(proof.name, subjectId, withinMs, now);
const age = now - proof.issuedAt;
const expired = proof.expiresAt != null && proof.expiresAt <= now;
if (age > withinMs || expired) {
if (result !== null) {
throw new Error(
`returned proof past withinMs window: age=${age}, withinMs=${withinMs}, expired=${expired}`
);
}
} else {
if (!result) {
throw new Error(
`expected non-null result, got null. age=${age}, withinMs=${withinMs}, expired=${expired}`
);
}
}
return result;
}
const report = await rigor.campaign(
[
rigor.fn('within', withinCheck,
rigor.args(
rigor.gen.object({
name: rigor.gen.constant('mfa'),
subject: rigor.gen.constant('user:abc'),
issuedAt: rigor.gen.int(0, 50000),
expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
}),
rigor.gen.int(1, 100000),
rigor.gen.int(0, 100000)
)
)
],
rigor.crucible([
rigor.invariant('within-window', ({ actual }) => actual !== undefined)
])
).run({ seed: 'challenge-proof-within-ms', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') {
console.log('TAP:', report.toTAP());
}
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'within-window');
assert.ok(inv);
assert.equal(inv.passed, true,
`withinMs window not enforced: ${inv.failureCount} failures`);
});
});