2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
({ error, errorMessage }) => !error && !errorMessage
|
|
|
|
|
)
|
|
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'challenge-proof-oracle', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'challenge-proof-expired', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-01 22:58:32 -07:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-31 13:44:06 -07:00
|
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'challenge-proof-most-recent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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,
|
2026-08-01 22:58:32 -07:00
|
|
|
`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) }))));
|
2026-07-31 13:44:06 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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', ({ error, errorMessage }) => !error && !errorMessage)
|
|
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'challenge-proof-within-ms', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
});
|
|
|
|
|
});
|