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