304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
|
|
import { test, describe, it, beforeEach } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { Arbiter } from '../../src/index.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cache Invalidation Tests
|
||
|
|
*
|
||
|
|
* Tests that caches are properly invalidated when relations are modified,
|
||
|
|
* ensuring that authorization results reflect the current state of the graph.
|
||
|
|
*/
|
||
|
|
|
||
|
|
describe('Cache Invalidation Tests', () => {
|
||
|
|
let arbiter;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
arbiter = new Arbiter({ embeddingDimensions: 256 });
|
||
|
|
|
||
|
|
// Set up basic relations
|
||
|
|
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||
|
|
arbiter.setRelationConfig('can_read', { type: 'direct' });
|
||
|
|
|
||
|
|
// Create test entities
|
||
|
|
arbiter.addNode('user:alice', 'user');
|
||
|
|
arbiter.addNode('user:bob', 'user');
|
||
|
|
arbiter.addNode('group:engineering', 'group');
|
||
|
|
arbiter.addNode('group:qa', 'group');
|
||
|
|
arbiter.addNode('document:spec', 'document');
|
||
|
|
arbiter.addNode('document:test-plan', 'document');
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Direct Relation Cache Invalidation', () => {
|
||
|
|
it('invalidates cache when direct relations are added', () => {
|
||
|
|
// Initial state: Alice has no access
|
||
|
|
let result1 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 0);
|
||
|
|
|
||
|
|
// Add direct relation
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 1.0 });
|
||
|
|
|
||
|
|
// Should now have access (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 1.0);
|
||
|
|
// CI-001 fix: fast path → 'direct_match'
|
||
|
|
assert.equal(result2.reason, 'direct_match');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('invalidates cache when direct relations are removed', () => {
|
||
|
|
// Set up initial relation
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 1.0 });
|
||
|
|
|
||
|
|
// Verify access
|
||
|
|
let result1 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
|
||
|
|
// Remove relation
|
||
|
|
arbiter.removeRelation('user:alice', 'can_read', 'document:spec');
|
||
|
|
|
||
|
|
// Should no longer have access (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
// CI-001 fix: fast path → 'no_relation'
|
||
|
|
assert.equal(result2.reason, 'no_relation');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('invalidates cache when relation possibility is updated', () => {
|
||
|
|
// Set up initial relation with low possibility
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 0.3 });
|
||
|
|
|
||
|
|
// Verify initial access
|
||
|
|
let result1 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 0.3);
|
||
|
|
|
||
|
|
// Update relation with higher possibility
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 0.8 });
|
||
|
|
|
||
|
|
// Should reflect new possibility (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 0.8);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('invalidates cache when relation values are updated', () => {
|
||
|
|
// Set up relation with value
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', {
|
||
|
|
possibility: 1.0,
|
||
|
|
value: 100
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify initial access with value
|
||
|
|
let result1 = arbiter.check('user:alice', 'can_read', 'document:spec', { collectValues: true });
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
assert.ok(result1.collectedValues);
|
||
|
|
assert.equal(result1.collectedValues[0].value, 100);
|
||
|
|
|
||
|
|
// Update relation with new value
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', {
|
||
|
|
possibility: 1.0,
|
||
|
|
value: 200
|
||
|
|
});
|
||
|
|
|
||
|
|
// Should reflect new value (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'can_read', 'document:spec', { collectValues: true });
|
||
|
|
assert.equal(result2.possibility, 1.0);
|
||
|
|
assert.equal(result2.collectedValues[0].value, 200);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Tuple-to-Userset Cache Invalidation', () => {
|
||
|
|
it('invalidates cache when intermediate relations change', () => {
|
||
|
|
// Set up tuple-to-userset pattern
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:engineering');
|
||
|
|
arbiter.addRelation('document:spec', 'owner', 'group:engineering');
|
||
|
|
|
||
|
|
arbiter.setRelationConfig('group_access', {
|
||
|
|
type: 'tuple_to_userset',
|
||
|
|
tuplesetRelation: 'owner',
|
||
|
|
computedRelation: 'member_of'
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify initial access
|
||
|
|
let result1 = arbiter.check('user:alice', 'group_access', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
|
||
|
|
// Remove intermediate relation
|
||
|
|
arbiter.removeRelation('user:alice', 'member_of', 'group:engineering');
|
||
|
|
|
||
|
|
// Should no longer have access (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'group_access', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('invalidates cache when object relations change', () => {
|
||
|
|
// Set up initial state
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:engineering');
|
||
|
|
arbiter.addRelation('document:spec', 'owner', 'group:engineering');
|
||
|
|
|
||
|
|
arbiter.setRelationConfig('group_access', {
|
||
|
|
type: 'tuple_to_userset',
|
||
|
|
tuplesetRelation: 'owner',
|
||
|
|
computedRelation: 'member_of'
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify initial access
|
||
|
|
let result1 = arbiter.check('user:alice', 'group_access', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
|
||
|
|
// Change object ownership
|
||
|
|
arbiter.removeRelation('document:spec', 'owner', 'group:engineering');
|
||
|
|
arbiter.addRelation('document:spec', 'owner', 'group:qa');
|
||
|
|
|
||
|
|
// Alice should no longer have access (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'group_access', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Chain Rule Cache Invalidation', () => {
|
||
|
|
it('invalidates cache when chain relations change', () => {
|
||
|
|
// Set up chain: user → group → document
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:engineering');
|
||
|
|
arbiter.addRelation('group:engineering', 'can_access', 'document:spec');
|
||
|
|
|
||
|
|
arbiter.setRelationConfig('chain_access', {
|
||
|
|
type: 'chain',
|
||
|
|
steps: [
|
||
|
|
{ relation: 'member_of', direction: 'out' },
|
||
|
|
{ relation: 'can_access', direction: 'out' }
|
||
|
|
]
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify initial access
|
||
|
|
let result1 = arbiter.check('user:alice', 'chain_access', 'document:spec');
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
|
||
|
|
// Break the chain
|
||
|
|
arbiter.removeRelation('group:engineering', 'can_access', 'document:spec');
|
||
|
|
|
||
|
|
// Should no longer have access (cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'chain_access', 'document:spec');
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Relational Comparator Cache Invalidation', () => {
|
||
|
|
it('invalidates cache when compared values change', () => {
|
||
|
|
// Add missing nodes
|
||
|
|
arbiter.addNode('feature:premium', 'feature');
|
||
|
|
|
||
|
|
// Set up balance comparison
|
||
|
|
arbiter.addRelation('user:alice', 'has_balance', 'user:alice', { value: 1000 });
|
||
|
|
arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800 });
|
||
|
|
|
||
|
|
arbiter.setRelationConfig('balance_check', {
|
||
|
|
type: 'relational_comparator',
|
||
|
|
left: {
|
||
|
|
rule: { type: 'direct', relation: 'has_balance' },
|
||
|
|
extractValue: true
|
||
|
|
},
|
||
|
|
right: {
|
||
|
|
evaluateFrom: 'object',
|
||
|
|
rule: { type: 'direct', relation: 'has_price' },
|
||
|
|
extractValue: true
|
||
|
|
},
|
||
|
|
comparator: '>'
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify initial comparison (1000 > 800 = true)
|
||
|
|
let result1 = arbiter.check('user:alice', 'balance_check', 'feature:premium');
|
||
|
|
assert.equal(result1.possibility, 1.0);
|
||
|
|
|
||
|
|
// Update balance to be lower
|
||
|
|
arbiter.addRelation('user:alice', 'has_balance', 'user:alice', { value: 500 });
|
||
|
|
|
||
|
|
// Should reflect new comparison (500 > 800 = false, cache should be invalidated)
|
||
|
|
let result2 = arbiter.check('user:alice', 'balance_check', 'feature:premium');
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Cache Performance Under Load', () => {
|
||
|
|
it('maintains cache performance with frequent invalidations', () => {
|
||
|
|
const startTime = Date.now();
|
||
|
|
|
||
|
|
// Perform many authorization checks with cache invalidations
|
||
|
|
for (let i = 0; i < 100; i++) {
|
||
|
|
// Add missing nodes
|
||
|
|
arbiter.addNode(`document:test-${i}`, 'document');
|
||
|
|
|
||
|
|
// Add relation
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', `document:test-${i}`, { possibility: 1.0 });
|
||
|
|
|
||
|
|
// Check access
|
||
|
|
const result = arbiter.check('user:alice', 'can_read', `document:test-${i}`);
|
||
|
|
assert.equal(result.possibility, 1.0);
|
||
|
|
|
||
|
|
// Remove relation (should invalidate cache)
|
||
|
|
arbiter.removeRelation('user:alice', 'can_read', `document:test-${i}`);
|
||
|
|
|
||
|
|
// Check access again (should be 0)
|
||
|
|
const result2 = arbiter.check('user:alice', 'can_read', `document:test-${i}`);
|
||
|
|
assert.equal(result2.possibility, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const endTime = Date.now();
|
||
|
|
const duration = endTime - startTime;
|
||
|
|
|
||
|
|
// Should complete within reasonable time (cache invalidation shouldn't be too slow)
|
||
|
|
assert.ok(duration < 5000, `Cache invalidation took too long: ${duration}ms`);
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(`Cache invalidation performance: ${duration}ms for 100 operations`);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Cache Consistency', () => {
|
||
|
|
it('ensures cache consistency across multiple authorization checks', () => {
|
||
|
|
// Set up complex authorization scenario
|
||
|
|
arbiter.addRelation('user:alice', 'member_of', 'group:engineering');
|
||
|
|
arbiter.addRelation('document:spec', 'owner', 'group:engineering');
|
||
|
|
|
||
|
|
arbiter.setRelationConfig('group_access', {
|
||
|
|
type: 'tuple_to_userset',
|
||
|
|
tuplesetRelation: 'owner',
|
||
|
|
computedRelation: 'member_of'
|
||
|
|
});
|
||
|
|
|
||
|
|
// Perform multiple checks
|
||
|
|
const results = [];
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
results.push(arbiter.check('user:alice', 'group_access', 'document:spec'));
|
||
|
|
}
|
||
|
|
|
||
|
|
// All results should be identical (cache consistency)
|
||
|
|
const firstResult = results[0];
|
||
|
|
for (let i = 1; i < results.length; i++) {
|
||
|
|
assert.equal(results[i].possibility, firstResult.possibility);
|
||
|
|
assert.equal(results[i].reason, firstResult.reason);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles concurrent cache invalidations correctly', () => {
|
||
|
|
// Set up initial state
|
||
|
|
arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 1.0 });
|
||
|
|
|
||
|
|
// Perform multiple operations that should invalidate cache
|
||
|
|
const operations = [
|
||
|
|
() => arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 0.5 }),
|
||
|
|
() => arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 0.8 }),
|
||
|
|
() => arbiter.removeRelation('user:alice', 'can_read', 'document:spec'),
|
||
|
|
() => arbiter.addRelation('user:alice', 'can_read', 'document:spec', { possibility: 1.0 })
|
||
|
|
];
|
||
|
|
|
||
|
|
// Execute operations
|
||
|
|
for (const operation of operations) {
|
||
|
|
operation();
|
||
|
|
|
||
|
|
// Check that cache is properly invalidated
|
||
|
|
const result = arbiter.check('user:alice', 'can_read', 'document:spec');
|
||
|
|
assert.ok(typeof result.possibility === 'number');
|
||
|
|
assert.ok(result.possibility >= 0 && result.possibility <= 1);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|