157 lines
6.7 KiB
JavaScript
157 lines
6.7 KiB
JavaScript
|
|
import { test } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { Arbiter } from '../../src/index.js';
|
||
|
|
|
||
|
|
const REASONS = new Set([
|
||
|
|
'direct_match', 'no_relation', 'threshold_not_met', 'missing_node',
|
||
|
|
'no_config', 'cycle', 'deny', 'insufficient_confidence'
|
||
|
|
]);
|
||
|
|
const BINARY_REASONS = new Set([
|
||
|
|
'allow', 'deny', 'insufficient_confidence', 'cycle', 'missing_node', 'no_config'
|
||
|
|
]);
|
||
|
|
const VALIDITY_LABELS = ['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown'];
|
||
|
|
|
||
|
|
function validNumber(x) {
|
||
|
|
return typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
function validValidityBlock(v, includeMeta) {
|
||
|
|
assert.ok(v && typeof v === 'object', 'validity must be an object');
|
||
|
|
assert.ok(VALIDITY_LABELS.includes(v.label), `validity.label in enum, got ${v.label}`);
|
||
|
|
assert.equal(typeof v.operator, 'string');
|
||
|
|
assert.equal(typeof v.regime, 'string');
|
||
|
|
if (includeMeta) {
|
||
|
|
assert.equal(typeof v.conflictMass, 'number', 'full validity carries conflictMass');
|
||
|
|
assert.equal(typeof v.nonMaxitive, 'boolean', 'full validity carries nonMaxitive');
|
||
|
|
assert.ok(Array.isArray(v.sources), 'full validity carries sources');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertCanonical(result, { includeMeta = false, binary = false } = {}) {
|
||
|
|
assert.ok(result && typeof result === 'object', 'result must be an object');
|
||
|
|
assert.ok(validNumber(result.possibility), `possibility in [0,1], got ${result.possibility}`);
|
||
|
|
assert.ok(
|
||
|
|
binary ? BINARY_REASONS.has(result.reason) : REASONS.has(result.reason),
|
||
|
|
`reason in enum, got ${result.reason}`
|
||
|
|
);
|
||
|
|
assert.ok(validNumber(result.reliability), `reliability in [0,1], got ${result.reliability}`);
|
||
|
|
validValidityBlock(result.validity, includeMeta);
|
||
|
|
if (result.possibility === 0) {
|
||
|
|
assert.equal(result.reliability, 0, 'denied decision leaks no reliability');
|
||
|
|
}
|
||
|
|
if (binary) {
|
||
|
|
assert.equal(result.binary, true, 'binary mode marks the result');
|
||
|
|
}
|
||
|
|
// Result must be JSON-serializable (no circulars / no functions).
|
||
|
|
const roundTrip = JSON.parse(JSON.stringify(result));
|
||
|
|
assert.deepEqual(roundTrip, JSON.parse(JSON.stringify(roundTrip)), 'result round-trips');
|
||
|
|
// No enumerable key may be undefined.
|
||
|
|
for (const [k, v] of Object.entries(result)) {
|
||
|
|
if (v !== undefined) continue;
|
||
|
|
assert.fail(`result.${k} is undefined`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeEngine() {
|
||
|
|
const arbiter = new Arbiter();
|
||
|
|
arbiter.addNode('u:1', 'user');
|
||
|
|
arbiter.addNode('u:2', 'user');
|
||
|
|
arbiter.addNode('g:2', 'group');
|
||
|
|
arbiter.addNode('doc:9', 'doc');
|
||
|
|
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||
|
|
arbiter.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.9 });
|
||
|
|
arbiter.addRelation('u:1', 'member_of', 'g:2', { possibility: 0.7 });
|
||
|
|
arbiter.addRelation('u:2', 'member_of', 'g:2', { possibility: 0.8 });
|
||
|
|
arbiter.addRelation('g:2', 'owner', 'doc:9', { possibility: 1.0 });
|
||
|
|
return arbiter;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('contract: every outcome class carries the canonical result shape', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
const outcomes = {
|
||
|
|
allowed: () => a.check('u:1', 'owner', 'doc:9'),
|
||
|
|
denied_no_relation: () => a.check('u:2', 'owner', 'doc:9'),
|
||
|
|
missing_node: () => a.check('ghost', 'owner', 'doc:9'),
|
||
|
|
no_config: () => a.check('u:1', 'undeclared_rel', 'doc:9')
|
||
|
|
};
|
||
|
|
for (const [name, fn] of Object.entries(outcomes)) {
|
||
|
|
const plain = fn();
|
||
|
|
assertCanonical(plain, {});
|
||
|
|
const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
|
||
|
|
assertCanonical(meta, { includeMeta: true });
|
||
|
|
assert.ok(meta.meta && typeof meta.meta === 'object', `${name}: includeMeta attaches meta`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: deny carries zero reliability but never leaks relation reliability', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
a.addRelation('u:2', 'owner', 'doc:9', { possibility: 0.6, reliability: 0.95 });
|
||
|
|
const denied = a.check('u:2', 'owner', 'doc:9', { includeMeta: true });
|
||
|
|
assertCanonical(denied, { includeMeta: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: includeMeta vs plain differ only in meta richness, never semantics', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
const plain = a.check('u:1', 'owner', 'doc:9');
|
||
|
|
const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
|
||
|
|
assert.equal(plain.possibility, meta.possibility);
|
||
|
|
assert.equal(plain.reason, meta.reason);
|
||
|
|
assert.equal(plain.reliability, meta.reliability);
|
||
|
|
assert.ok(meta.meta, 'includeMeta attaches meta');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: binary mode marks results and keeps the canonical shape', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
a.enableCondensedSnapshot();
|
||
|
|
const allowed = a.check('u:1', 'owner', 'doc:9', { binary: true });
|
||
|
|
assertCanonical(allowed, { binary: true });
|
||
|
|
const denied = a.check('u:2', 'owner', 'doc:9', { binary: true });
|
||
|
|
assertCanonical(denied, { binary: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: snapshot round-trip preserves check outcomes', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
a.enableCondensedSnapshot();
|
||
|
|
const buf = a.toSnapshotBinary();
|
||
|
|
const restored = Arbiter.fromSnapshotBinary(buf);
|
||
|
|
const before = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
|
||
|
|
const after = restored.check('u:1', 'owner', 'doc:9', { includeMeta: true });
|
||
|
|
assert.equal(before.possibility, after.possibility);
|
||
|
|
assert.equal(before.reason, after.reason);
|
||
|
|
assert.equal(before.reliability, after.reliability);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: explain enriches meta but keeps the canonical shape', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
const plain = a.check('u:1', 'owner', 'doc:9');
|
||
|
|
const explained = a.check('u:1', 'owner', 'doc:9', { explain: true });
|
||
|
|
assertCanonical(explained, { includeMeta: true });
|
||
|
|
assert.equal(explained.possibility, plain.possibility);
|
||
|
|
assert.equal(explained.reason, plain.reason);
|
||
|
|
assert.ok(explained.meta.allow, 'explain carries allow block');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: isReachable returns boolean | null (null = deferred to rules)', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
a.initializeReachabilityChecker();
|
||
|
|
for (let i = 0; i < 8; i++) {
|
||
|
|
const r = a.isReachable('u:1', 'doc:9');
|
||
|
|
assert.ok(r === true || r === false || r === null, `isReachable is boolean|null, got ${r}`);
|
||
|
|
}
|
||
|
|
a.enableCondensedSnapshot();
|
||
|
|
const r2 = a.isReachable('u:1', 'doc:9');
|
||
|
|
assert.ok(r2 === true || r2 === false || r2 === null, `binary isReachable is boolean|null, got ${r2}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('contract: node/relation management is idempotent for reads', () => {
|
||
|
|
const a = makeEngine();
|
||
|
|
assert.equal(typeof a.getNodeData('u:1'), 'object');
|
||
|
|
assert.equal(typeof a.resolveNodeId('u:1'), 'number');
|
||
|
|
assert.equal(a.resolveNodeId('ghost'), undefined);
|
||
|
|
const before = a.check('u:1', 'owner', 'doc:9');
|
||
|
|
a.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.95 }); // re-declare, not a crash
|
||
|
|
const after = a.check('u:1', 'owner', 'doc:9');
|
||
|
|
assert.equal(typeof after.possibility, 'number');
|
||
|
|
});
|