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,206 @@
|
||||
/**
|
||||
* decision-cache-port.test.js — unit tests for the DecisionCache port.
|
||||
*
|
||||
* Verifies:
|
||||
* - Default DecisionCache(arbiter) forwards to arbiter's cache fields
|
||||
* - TTL behavior (via injected clock)
|
||||
* - disableCaching / disableDirectCaching gates are honored
|
||||
* - NullDecisionCache is fully inert
|
||||
* - peekDirect distinguishes hit / expired / miss / disabled
|
||||
* - invalidateByNodeKey removes entries that include the node id
|
||||
* - invalidateByRelation clears dependent rule-result entries
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DecisionCache, NullDecisionCache } from '../../src/authorization/DecisionCache.js';
|
||||
import { Arbiter } from '../../src/index.js';
|
||||
|
||||
describe('DecisionCache', () => {
|
||||
describe('NullDecisionCache', () => {
|
||||
it('every operation is a no-op', () => {
|
||||
const c = new NullDecisionCache();
|
||||
assert.equal(c.enabled, false);
|
||||
assert.equal(c.directEnabled, false);
|
||||
assert.equal(c.ruleEnabled, false);
|
||||
assert.equal(c.getDirect('k'), undefined);
|
||||
assert.equal(c.getRule('k'), undefined);
|
||||
c.setDirect('k', { x: 1 });
|
||||
c.setRule('k', { x: 2 });
|
||||
c.invalidateByRelation('rel');
|
||||
c.invalidateByNodeKey('node');
|
||||
c.invalidateAll();
|
||||
// No throws.
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default DecisionCache(arbiter) forwarding', () => {
|
||||
it('reads + writes direct cache through arbiter fields', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
assert.equal(cache.enabled, true);
|
||||
assert.equal(cache.directEnabled, true);
|
||||
|
||||
cache.setDirect('key-a', { result: 'A', possibility: 1 });
|
||||
const hit = cache.getDirect('key-a');
|
||||
assert.deepEqual(hit, { result: 'A', possibility: 1 });
|
||||
});
|
||||
|
||||
it('reads + writes rule cache through arbiter fields', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
cache.setRule('rkey-1', { reason: 'allow_rule_matched' });
|
||||
const hit = cache.getRule('rkey-1');
|
||||
assert.deepEqual(hit, { reason: 'allow_rule_matched' });
|
||||
});
|
||||
|
||||
it('honors disableCaching', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, disableCaching: true });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
assert.equal(cache.enabled, false);
|
||||
assert.equal(cache.directEnabled, false);
|
||||
assert.equal(cache.ruleEnabled, false);
|
||||
cache.setDirect('k', { x: 1 });
|
||||
assert.equal(cache.getDirect('k'), undefined);
|
||||
});
|
||||
|
||||
it('honors disableDirectCaching (but not rule cache)', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, disableDirectCaching: true });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
assert.equal(cache.enabled, true);
|
||||
assert.equal(cache.directEnabled, false);
|
||||
assert.equal(cache.ruleEnabled, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('peekDirect status taxonomy', () => {
|
||||
it('returns "miss" on absent key', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
const [result, status] = cache.peekDirect('never-set');
|
||||
assert.equal(result, undefined);
|
||||
assert.equal(status, 'miss');
|
||||
});
|
||||
|
||||
it('returns "hit" within TTL', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, directCheckCacheTTL: 60_000 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
cache.setDirect('k', { reason: 'allow_rule_matched' });
|
||||
const [result, status] = cache.peekDirect('k');
|
||||
assert.equal(status, 'hit');
|
||||
assert.deepEqual(result, { reason: 'allow_rule_matched' });
|
||||
});
|
||||
|
||||
it('returns "expired" past TTL when entry still present', () => {
|
||||
let now = 1_000_000;
|
||||
const clock = () => now;
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, directCheckCacheTTL: 1_000 });
|
||||
const cache = new DecisionCache(arbiter, { clock });
|
||||
cache.setDirect('k', { reason: 'stale' });
|
||||
// Advance clock past TTL
|
||||
now += 2_000;
|
||||
const [result, status] = cache.peekDirect('k');
|
||||
assert.equal(status, 'expired');
|
||||
assert.deepEqual(result, { reason: 'stale' });
|
||||
});
|
||||
|
||||
it('returns "disabled" when caching is off', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, disableDirectCaching: true });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
const [, status] = cache.peekDirect('k');
|
||||
assert.equal(status, 'disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateByNodeKey', () => {
|
||||
// RF-04 closure — the invalidation now actually works because
|
||||
// the upgraded @tenere/hyperbolic-lru@1.0.3 exposes
|
||||
// invalidateByPattern(). The port delegates to that method
|
||||
// (with a digit-boundary regex to avoid partial-number matches).
|
||||
it('removes entries whose composite key contains the node id', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('user:bob', 'user');
|
||||
arbiter.addNode('user:carol', 'user');
|
||||
const aliceId = arbiter.resolveNodeId('user:alice');
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
const carolId = arbiter.resolveNodeId('user:carol');
|
||||
|
||||
// Real cache-key format: `${srcId}|${rel}|${dstId}` (no prefix).
|
||||
cache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'a-b' });
|
||||
cache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'b-a' });
|
||||
cache.setDirect(`${carolId}|member_of|${bobId}`, { reason: 'c-b' });
|
||||
cache.setDirect('unrelated-key', { reason: 'u' });
|
||||
|
||||
cache.invalidateByNodeKey('user:alice');
|
||||
|
||||
// Both alice-involving entries cleared; the others remain.
|
||||
assert.equal(cache.getDirect(`${aliceId}|member_of|${bobId}`), undefined);
|
||||
assert.equal(cache.getDirect(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.ok(cache.getDirect(`${carolId}|member_of|${bobId}`));
|
||||
assert.ok(cache.getDirect('unrelated-key'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateByRelation', () => {
|
||||
it('clears rule-result entries tracked for the relation', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
const cache = new DecisionCache(arbiter);
|
||||
cache.setRule('rkey-a', { possibility: 1 });
|
||||
cache.trackRuleKeyForRelation('member_of', 'rkey-a');
|
||||
|
||||
cache.invalidateByRelation('member_of');
|
||||
|
||||
assert.equal(cache.getRule('rkey-a'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DecisionCache is wired into Arbiter', () => {
|
||||
it('arbiter.decisionCache is a DecisionCache instance', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
assert.ok(arbiter.decisionCache);
|
||||
assert.ok(arbiter.decisionCache instanceof DecisionCache);
|
||||
});
|
||||
|
||||
it('AuthorizationChecker receives the same DecisionCache', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
assert.equal(arbiter.authChecker.decisionCache, arbiter.decisionCache);
|
||||
});
|
||||
|
||||
it('NodeManager receives the same DecisionCache', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
assert.equal(arbiter.nodeManager.decisionCache, arbiter.decisionCache);
|
||||
});
|
||||
|
||||
it('Arbiter accepts an injected DecisionCache (NullDecisionCache for testing)', () => {
|
||||
const cache = new NullDecisionCache();
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64, decisionCache: cache });
|
||||
assert.equal(arbiter.decisionCache, cache);
|
||||
assert.equal(arbiter.authChecker.decisionCache, cache);
|
||||
assert.equal(arbiter.nodeManager.decisionCache, cache);
|
||||
});
|
||||
|
||||
it('NodeManager.updateNodeData invalidates via the port when given a nodeId-bearing key', () => {
|
||||
const arbiter = new Arbiter({ embeddingDimensions: 64 });
|
||||
arbiter.addNode('user:alice', 'user');
|
||||
arbiter.addNode('user:bob', 'user');
|
||||
const aliceId = arbiter.resolveNodeId('user:alice');
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
// Pre-populate cache with a key that contains alice's id
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|other|${bobId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect('unrelated', { reason: 'keep' });
|
||||
|
||||
// Trigger invalidation through the NodeManager path
|
||||
arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' });
|
||||
|
||||
// All alice-bearing keys should be gone; unrelated key remains.
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|member_of|${bobId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|other|${bobId}`), undefined);
|
||||
assert.ok(arbiter.decisionCache.getDirect('unrelated'));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user