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