/** * 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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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', ({ error, errorMessage }) => !error && !errorMessage) ]) ).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`); }); });