initial commit: @arbiter/core authorization engine with js-rigor hardening
Zanzibar-style authorization graph engine (direct/chain/TTU/defeasible/ binary modes, condensed snapshots, value relations) with 39 rigor test campaigns. Includes fixes for snapshot binary writer/reader format mismatch (snapshot-of-snapshot corruption), possibility write-boundary validation, empty-graph snapshot serialization, relation lookup cache direction collision, config-redefinition cache invalidation, binary threshold semantics, defeasible compiled routing, and comparator reason whitelisting.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* 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({ effort: 1000 });
|
||||
|
||||
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({ effort: 1500 });
|
||||
|
||||
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({ effort: 800 });
|
||||
|
||||
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({ effort: 800 });
|
||||
|
||||
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({ effort: 500 });
|
||||
|
||||
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({ effort: 800 });
|
||||
|
||||
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`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user