322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
|
|
/**
|
||
|
|
* rigor/computed-rule.test.js — js-rigor property tests for ComputedRule.
|
||
|
|
*
|
||
|
|
* ComputedRule delegates to arbiter.authChecker.check(userKey, computedRelation,
|
||
|
|
* objectKey, options) and adapts the result. Properties verified:
|
||
|
|
*
|
||
|
|
* - result.possibility equals the delegated authChecker.check result.possibility
|
||
|
|
* - result.reason defaults to 'computed_delegation' if delegated has no reason
|
||
|
|
* - result.reason passes through the delegated reason when present
|
||
|
|
* - meta.ruleType='computed' and meta.computedRelation=rule.relation
|
||
|
|
* - meta.delegated=true
|
||
|
|
* - collectedValues pass through from delegated result
|
||
|
|
* - trackEvaluation=true produces a populated result.evaluation block
|
||
|
|
* - result.shape is stable across many inputs
|
||
|
|
*/
|
||
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { rigor } from '@rigor/core';
|
||
|
|
import { ComputedRule } from '../../src/authorization/rules/ComputedRule.js';
|
||
|
|
|
||
|
|
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build an arbiter stub whose authChecker.check returns a programmable value.
|
||
|
|
* Records all calls for assertions.
|
||
|
|
*/
|
||
|
|
function makeArbiter(delegate) {
|
||
|
|
const calls = [];
|
||
|
|
const arbiter = {
|
||
|
|
authChecker: {
|
||
|
|
check(userKey, computedRelation, objectKey, options) {
|
||
|
|
calls.push({ userKey, computedRelation, objectKey, hasVisited: !!options._visited, hasCurrentRel: !!options._currentRelation });
|
||
|
|
return delegate(userKey, computedRelation, objectKey, options);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return { arbiter, calls };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('ComputedRule evaluation (rigor)', () => {
|
||
|
|
it('result.possibility equals delegated authChecker.check result.possibility', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey, possibility) {
|
||
|
|
const { arbiter, calls } = makeArbiter(() => ({ possibility, reliability: 1.0 }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (result.possibility !== possibility) {
|
||
|
|
throw new Error(`result.possibility=${result.possibility}, expected ${possibility}`);
|
||
|
|
}
|
||
|
|
// Delegation must have happened with the rule.relation as the computed relation
|
||
|
|
if (calls.length !== 1) throw new Error(`expected 1 authChecker.check call, got ${calls.length}`);
|
||
|
|
if (calls[0].userKey !== userKey) throw new Error(`userKey=${calls[0].userKey}, expected ${userKey}`);
|
||
|
|
if (calls[0].computedRelation !== computedRel) throw new Error(`computedRelation=${calls[0].computedRelation}, expected ${computedRel}`);
|
||
|
|
if (calls[0].objectKey !== objectKey) throw new Error(`objectKey=${calls[0].objectKey}, expected ${objectKey}`);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.float({ min: 0, max: 1 })
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('possibility-passthrough', ({ 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 === 'possibility-passthrough');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `possibility passthrough violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('result.reason defaults to "computed_delegation" when delegated has no reason', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey) {
|
||
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (result.reason !== 'computed_delegation') {
|
||
|
|
throw new Error(`reason=${result.reason}, expected 'computed_delegation'`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('reason-default', ({ 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 === 'reason-default');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `reason default violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('result.reason passes through the delegated reason when present', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey, reason) {
|
||
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, reason }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (result.reason !== reason) {
|
||
|
|
throw new Error(`reason=${result.reason}, expected ${reason}`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(['direct_match', 'no_relation', 'inferred', 'chain_match', 'computed_delegation'])
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('reason-passthrough', ({ 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 === 'reason-passthrough');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `reason passthrough violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('meta.ruleType="computed" and meta.computedRelation=rule.relation', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey) {
|
||
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{ includeMeta: true, trackEvaluation: false }
|
||
|
|
);
|
||
|
|
if (!result.meta) throw new Error(`result.meta is missing (full result: ${JSON.stringify(result)})`);
|
||
|
|
if (result.meta.ruleType !== 'computed') {
|
||
|
|
throw new Error(`meta.ruleType=${result.meta.ruleType}, expected 'computed' (full meta: ${JSON.stringify(result.meta)})`);
|
||
|
|
}
|
||
|
|
if (result.meta.computedRelation !== computedRel) {
|
||
|
|
throw new Error(`meta.computedRelation=${result.meta.computedRelation}, expected ${computedRel}`);
|
||
|
|
}
|
||
|
|
if (result.meta.delegated !== true) {
|
||
|
|
throw new Error(`meta.delegated=${result.meta.delegated}, expected true`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('meta-contract', ({ 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 === 'meta-contract');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `meta contract violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('collectedValues pass through from delegated result', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey, nValues) {
|
||
|
|
const values = Array.from({ length: nValues }, (_, i) => ({
|
||
|
|
value: i + 1,
|
||
|
|
possibility: 0.5,
|
||
|
|
path: [userKey, objectKey],
|
||
|
|
source: { entityKey: userKey, relation: computedRel, step: 0 },
|
||
|
|
metadata: { timestamp: 1000, reliability: 1.0 }
|
||
|
|
}));
|
||
|
|
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, collectedValues: values }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (result.collectedValues.length !== nValues) {
|
||
|
|
throw new Error(`collectedValues.length=${result.collectedValues.length}, expected ${nValues}`);
|
||
|
|
}
|
||
|
|
for (let i = 0; i < nValues; i++) {
|
||
|
|
if (result.collectedValues[i].value !== values[i].value) {
|
||
|
|
throw new Error(`collectedValues[${i}].value=${result.collectedValues[i].value}, expected ${values[i].value}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.int(0, 5)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('collected-values-passthrough', ({ 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 === 'collected-values-passthrough');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `collected values passthrough violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('result.possibility defaults to 0 when delegated has no possibility', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey) {
|
||
|
|
const { arbiter } = makeArbiter(() => ({ reliability: 1.0 })); // no possibility
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const result = rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
new Set(),
|
||
|
|
'unused',
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (result.possibility !== 0) {
|
||
|
|
throw new Error(`result.possibility=${result.possibility}, expected 0 (fallback when delegated is undefined)`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('possibility-fallback-zero', ({ 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 === 'possibility-fallback-zero');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `possibility fallback violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('authChecker.check is called with the visited set and currentRelation passed through options', async () => {
|
||
|
|
async function check(userKey, computedRel, objectKey, currentRel) {
|
||
|
|
const { arbiter, calls } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
|
||
|
|
const rule = new ComputedRule(arbiter);
|
||
|
|
const visited = new Set([`visited:1`, `visited:2`]);
|
||
|
|
rule.evaluate(
|
||
|
|
0, userKey, 1, objectKey,
|
||
|
|
{ type: 'computed', relation: computedRel },
|
||
|
|
visited,
|
||
|
|
currentRel,
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
if (calls.length !== 1) throw new Error(`expected 1 call, got ${calls.length}`);
|
||
|
|
if (!calls[0].hasVisited) throw new Error('authChecker.check did not receive options._visited');
|
||
|
|
if (!calls[0].hasCurrentRel) throw new Error('authChecker.check did not receive options._currentRelation');
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.string(1, 30),
|
||
|
|
rigor.gen.enum(RELATIONS)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('options-passthrough', ({ 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 === 'options-passthrough');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `options passthrough violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
});
|