Files
core/tests/rigor/challenge-proof.test.js
T

279 lines
9.9 KiB
JavaScript
Raw Normal View History

/**
* 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
)
])
).run({ effort: 1500 });
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)
])
).run({ effort: 800 });
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('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)
])
).run({ effort: 800 });
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`);
});
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)
])
).run({ effort: 1500 });
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`);
});
});