f6e3ae1922
Completes the rolling-hash rollout (previously only createChainKey hashed): - createCompositeKey, createSrcRelKey, createDstRelKey, and the valueRelationsBySrc/Dst keys now produce 53-bit rolling hashes instead of `src|rel|dst` string concatenation. Direction markers keep srcRel vs dstRel distinct; the composite/chain keys are exact integers usable as Map keys. - Direct-check cache invalidation is now key-TRACKED instead of pattern- matched: every direct-check result is registered under the checked relation, its base relations (reverse dependency index), and the subject/object node ids (Arbiter._trackDirectCheckKey). Relation-level invalidation deletes the tracked keys for each affected relation (covering config-override checks); node-level invalidation (node removal / updateNodeData) deletes by node id. This replaces the pipe-delimited-string regex matching that required the old key format. - DecisionCache.invalidateByNodeKey and invalidateAll route through the tracked indexes; tracking maps are cleared on full flush. Tests updated to the tracked contract (register injected keys via _trackDirectCheckKey); full suite green.
220 lines
9.6 KiB
JavaScript
220 lines
9.6 KiB
JavaScript
/**
|
|
* 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'));
|
|
});
|
|
});
|
|
}); |