Files

220 lines
9.6 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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');
// Keys are rolling hashes; node-level invalidation uses the per-node
// key index, so every injected key must be tracked.
const keyAB = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
const keyBA = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
const keyCB = arbiter.keyManager.createCompositeKey(carolId, 'member_of', bobId);
cache.setDirect(keyAB, { reason: 'a-b' });
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, keyAB);
cache.setDirect(keyBA, { reason: 'b-a' });
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, keyBA);
cache.setDirect(keyCB, { reason: 'c-b' });
arbiter._trackDirectCheckKey('member_of', carolId, bobId, keyCB);
cache.setDirect('unrelated-key', { reason: 'u' });
cache.invalidateByNodeKey('user:alice');
// Both alice-involving entries cleared; the others remain.
assert.equal(cache.getDirect(keyAB), undefined);
assert.equal(cache.getDirect(keyBA), undefined);
assert.ok(cache.getDirect(keyCB));
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 keys touching alice (tracked so node-level
// invalidation can find them)
const k1 = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
const k2 = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
const k3 = arbiter.keyManager.createCompositeKey(aliceId, 'other', bobId);
arbiter.decisionCache.setDirect(k1, { reason: 'stale' });
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, k1);
arbiter.decisionCache.setDirect(k2, { reason: 'stale' });
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, k2);
arbiter.decisionCache.setDirect(k3, { reason: 'stale' });
arbiter._trackDirectCheckKey('other', aliceId, bobId, k3);
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(k1), undefined);
assert.equal(arbiter.decisionCache.getDirect(k2), undefined);
assert.equal(arbiter.decisionCache.getDirect(k3), undefined);
assert.ok(arbiter.decisionCache.getDirect('unrelated'));
});
});
});