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'); });