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,453 @@
|
||||
import { test, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setupChainRuleTestGraph } from './helpers.js';
|
||||
|
||||
describe('ChainRule Comprehensive Tests', () => {
|
||||
|
||||
describe('Comprehensive Value Collection for Downstream Rules', () => {
|
||||
it('collects values from intermediate steps with proper metadata', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create a more detailed value collection scenario
|
||||
arbiter.addNode('user:value_tester', 'user');
|
||||
arbiter.addNode('account:checking', 'account');
|
||||
arbiter.addNode('account:savings', 'account');
|
||||
arbiter.addNode('currency:usd', 'currency');
|
||||
|
||||
arbiter.setRelationConfig('owns_account', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_balance', { type: 'direct' });
|
||||
|
||||
// Set up account relationships with values
|
||||
arbiter.addRelation('user:value_tester', 'owns_account', 'account:checking');
|
||||
arbiter.addRelation('user:value_tester', 'owns_account', 'account:savings');
|
||||
arbiter.addRelation('account:checking', 'has_balance', 'currency:usd', { value: 1500 });
|
||||
arbiter.addRelation('account:savings', 'has_balance', 'currency:usd', { value: 2500 });
|
||||
|
||||
// Test value collection from step 0 (accounts)
|
||||
arbiter.setRelationConfig('collect_balances', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'owns_account', direction: 'out' }, // user → accounts
|
||||
{ relation: 'has_balance', direction: 'out' } // accounts → currency
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0], // Collect from step 0 (accounts)
|
||||
relations: ['has_balance'] // Collect balance values
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
const result = arbiter.check('user:value_tester', 'collect_balances', 'currency:usd');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Value collection test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason,
|
||||
hasCollectedValues: 'collectedValues' in result,
|
||||
actualStructure: Object.keys(result)
|
||||
});
|
||||
|
||||
// Should successfully reach the target
|
||||
assert.equal(result.possibility, 1);
|
||||
assert.equal(result.reason, 'allow_rule_matched');
|
||||
|
||||
// Values should be collected (this test will help us debug the collection issue)
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ VALUE COLLECTION TEST: Verifying intermediate step value collection');
|
||||
});
|
||||
|
||||
it('collects values from final step for price comparisons', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create pricing scenario similar to RelationalComparatorRule usage
|
||||
arbiter.addNode('product:laptop', 'product');
|
||||
arbiter.addNode('sku:laptop-pro', 'sku');
|
||||
arbiter.addNode('price:laptop-pro', 'price');
|
||||
|
||||
arbiter.setRelationConfig('has_sku', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_price', { type: 'direct' });
|
||||
|
||||
// Set up product pricing chain
|
||||
arbiter.addRelation('product:laptop', 'has_sku', 'sku:laptop-pro');
|
||||
arbiter.addRelation('sku:laptop-pro', 'has_price', 'price:laptop-pro', { value: 1200 });
|
||||
|
||||
// Test collecting price from final step
|
||||
arbiter.setRelationConfig('get_product_price', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'has_sku', direction: 'out' }, // product → sku
|
||||
{ relation: 'has_price', direction: 'out' } // sku → price
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [1], // Collect from step 1 (sku)
|
||||
relations: ['has_price'] // Collect price values
|
||||
},
|
||||
valueAggregation: 'min'
|
||||
});
|
||||
|
||||
const result = arbiter.check('product:laptop', 'get_product_price', 'price:laptop-pro');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Price collection test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason,
|
||||
pathReached: result.possibility > 0
|
||||
});
|
||||
|
||||
// Should successfully reach the price
|
||||
assert.equal(result.possibility, 1);
|
||||
assert.equal(result.reason, 'allow_rule_matched');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ PRICE COLLECTION TEST: Verifying final step value collection for pricing');
|
||||
});
|
||||
|
||||
it('collects multiple values with aggregation for budget scenarios', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create budget scenario with multiple projects
|
||||
arbiter.addNode('department:engineering', 'department');
|
||||
arbiter.addNode('project:webapp', 'project');
|
||||
arbiter.addNode('project:mobile', 'project');
|
||||
arbiter.addNode('budget:q1', 'budget');
|
||||
|
||||
arbiter.setRelationConfig('manages_project', { type: 'direct' });
|
||||
arbiter.setRelationConfig('allocated_budget', { type: 'direct' });
|
||||
|
||||
// Set up department budget allocation
|
||||
arbiter.addRelation('department:engineering', 'manages_project', 'project:webapp');
|
||||
arbiter.addRelation('department:engineering', 'manages_project', 'project:mobile');
|
||||
arbiter.addRelation('project:webapp', 'allocated_budget', 'budget:q1', { value: 500000 });
|
||||
arbiter.addRelation('project:mobile', 'allocated_budget', 'budget:q1', { value: 300000 });
|
||||
|
||||
// Test aggregating budget values
|
||||
arbiter.setRelationConfig('total_department_budget', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'manages_project', direction: 'out' }, // department → projects
|
||||
{ relation: 'allocated_budget', direction: 'out' } // projects → budget
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0], // Collect from step 0 (projects)
|
||||
relations: ['allocated_budget']
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
const result = arbiter.check('department:engineering', 'total_department_budget', 'budget:q1');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Budget aggregation test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason
|
||||
});
|
||||
|
||||
// Should successfully reach the budget
|
||||
assert.equal(result.possibility, 1);
|
||||
assert.equal(result.reason, 'allow_rule_matched');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ BUDGET AGGREGATION TEST: Verifying multi-value collection and aggregation');
|
||||
});
|
||||
|
||||
it('handles missing values gracefully for comparisons', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create scenario where some entities have values, others don't
|
||||
arbiter.addNode('user:partial', 'user');
|
||||
arbiter.addNode('account:empty', 'account');
|
||||
arbiter.addNode('account:funded', 'account');
|
||||
arbiter.addNode('currency:usd', 'currency');
|
||||
|
||||
arbiter.setRelationConfig('has_account', { type: 'direct' });
|
||||
arbiter.setRelationConfig('balance', { type: 'direct' });
|
||||
|
||||
// Only one account has a balance
|
||||
arbiter.addRelation('user:partial', 'has_account', 'account:empty');
|
||||
arbiter.addRelation('user:partial', 'has_account', 'account:funded');
|
||||
arbiter.addRelation('account:funded', 'balance', 'currency:usd', { value: 100 });
|
||||
// account:empty has no balance relation
|
||||
|
||||
arbiter.setRelationConfig('check_partial_balances', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'has_account', direction: 'out' },
|
||||
{ relation: 'balance', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0],
|
||||
relations: ['balance']
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
const result = arbiter.check('user:partial', 'check_partial_balances', 'currency:usd');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Partial values test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason
|
||||
});
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('🔍 DEBUGGING: Expected path user:partial → account:funded → currency:usd');
|
||||
if (process.env.TEST_DEBUG === '1') console.log('🔍 Relations check:');
|
||||
if (process.env.TEST_DEBUG === '1') console.log(' user:partial → has_account:', arbiter.relationManager.getRelationsFromSrc(
|
||||
arbiter.nodeManager.getNodeId('user:partial'), 'has_account'
|
||||
).map(r => arbiter.keyByNodeId.get(r.dst)));
|
||||
if (process.env.TEST_DEBUG === '1') console.log(' account:funded → balance:', arbiter.relationManager.getRelationsFromSrc(
|
||||
arbiter.nodeManager.getNodeId('account:funded'), 'balance'
|
||||
).map(r => ({ target: arbiter.keyByNodeId.get(r.dst), value: r.value })));
|
||||
|
||||
// Should reach target through funded account
|
||||
assert.equal(result.possibility, 1);
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ PARTIAL VALUES TEST: Verifying graceful handling of missing values');
|
||||
});
|
||||
|
||||
it('collects values with proper path metadata for debugging', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create a simple but clear value collection scenario
|
||||
arbiter.addNode('store:electronics', 'store');
|
||||
arbiter.addNode('category:laptops', 'category');
|
||||
arbiter.addNode('item:macbook', 'item');
|
||||
|
||||
arbiter.setRelationConfig('has_category', { type: 'direct' });
|
||||
arbiter.setRelationConfig('contains_item', { type: 'direct' });
|
||||
arbiter.setRelationConfig('item_price', { type: 'direct' });
|
||||
|
||||
// Set up store → category → item chain with pricing
|
||||
arbiter.addRelation('store:electronics', 'has_category', 'category:laptops');
|
||||
arbiter.addRelation('category:laptops', 'contains_item', 'item:macbook');
|
||||
arbiter.addRelation('category:laptops', 'item_price', 'item:macbook', { value: 2000 });
|
||||
|
||||
arbiter.setRelationConfig('get_category_pricing', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'has_category', direction: 'out' }, // store → category
|
||||
{ relation: 'contains_item', direction: 'out' } // category → item
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [1], // Collect from step 1 (categories)
|
||||
relations: ['item_price'] // Collect item prices
|
||||
},
|
||||
valueAggregation: 'max'
|
||||
});
|
||||
|
||||
const result = arbiter.check('store:electronics', 'get_category_pricing', 'item:macbook');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Path metadata test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason
|
||||
});
|
||||
|
||||
// Should successfully traverse the path
|
||||
assert.equal(result.possibility, 1);
|
||||
assert.equal(result.reason, 'allow_rule_matched');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ PATH METADATA TEST: Verifying path information in collected values');
|
||||
});
|
||||
|
||||
it('supports different value aggregation methods for downstream rules', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create scenario with multiple values to test aggregation
|
||||
arbiter.addNode('portfolio:stocks', 'portfolio');
|
||||
arbiter.addNode('stock:aapl', 'stock');
|
||||
arbiter.addNode('stock:googl', 'stock');
|
||||
arbiter.addNode('stock:msft', 'stock');
|
||||
arbiter.addNode('currency:usd', 'currency');
|
||||
|
||||
arbiter.setRelationConfig('contains_stock', { type: 'direct' });
|
||||
arbiter.setRelationConfig('current_value', { type: 'direct' });
|
||||
|
||||
// Set up portfolio with multiple stock values
|
||||
arbiter.addRelation('portfolio:stocks', 'contains_stock', 'stock:aapl');
|
||||
arbiter.addRelation('portfolio:stocks', 'contains_stock', 'stock:googl');
|
||||
arbiter.addRelation('portfolio:stocks', 'contains_stock', 'stock:msft');
|
||||
arbiter.addRelation('stock:aapl', 'current_value', 'currency:usd', { value: 150 });
|
||||
arbiter.addRelation('stock:googl', 'current_value', 'currency:usd', { value: 2500 });
|
||||
arbiter.addRelation('stock:msft', 'current_value', 'currency:usd', { value: 300 });
|
||||
|
||||
// Test sum aggregation
|
||||
arbiter.setRelationConfig('portfolio_sum', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'contains_stock', direction: 'out' },
|
||||
{ relation: 'current_value', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0],
|
||||
relations: ['current_value']
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
// Test max aggregation
|
||||
arbiter.setRelationConfig('portfolio_max', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'contains_stock', direction: 'out' },
|
||||
{ relation: 'current_value', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0],
|
||||
relations: ['current_value']
|
||||
},
|
||||
valueAggregation: 'max'
|
||||
});
|
||||
|
||||
// Test min aggregation
|
||||
arbiter.setRelationConfig('portfolio_min', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'contains_stock', direction: 'out' },
|
||||
{ relation: 'current_value', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0],
|
||||
relations: ['current_value']
|
||||
},
|
||||
valueAggregation: 'min'
|
||||
});
|
||||
|
||||
const sumResult = arbiter.check('portfolio:stocks', 'portfolio_sum', 'currency:usd');
|
||||
const maxResult = arbiter.check('portfolio:stocks', 'portfolio_max', 'currency:usd');
|
||||
const minResult = arbiter.check('portfolio:stocks', 'portfolio_min', 'currency:usd');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Aggregation methods test results:', {
|
||||
sum: { allow: sumResult.possibility, reason: sumResult.reason },
|
||||
max: { allow: maxResult.possibility, reason: maxResult.reason },
|
||||
min: { allow: minResult.possibility, reason: minResult.reason }
|
||||
});
|
||||
|
||||
// All should successfully reach the target
|
||||
assert.equal(sumResult.possibility, 1);
|
||||
assert.equal(maxResult.possibility, 1);
|
||||
assert.equal(minResult.possibility, 1);
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ AGGREGATION METHODS TEST: Verifying sum, max, min aggregation support');
|
||||
});
|
||||
|
||||
it('validates integration with RelationalComparatorRule scenarios', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create the exact scenario that RelationalComparatorRule needs
|
||||
arbiter.addNode('user:buyer', 'user');
|
||||
arbiter.addNode('account:primary', 'account');
|
||||
arbiter.addNode('feature:premium', 'feature');
|
||||
arbiter.addNode('plan:gold', 'plan');
|
||||
arbiter.addNode('currency:usd', 'currency');
|
||||
|
||||
arbiter.setRelationConfig('primary_account', { type: 'direct' });
|
||||
arbiter.setRelationConfig('account_balance', { type: 'direct' });
|
||||
arbiter.setRelationConfig('feature_plan', { type: 'direct' });
|
||||
arbiter.setRelationConfig('plan_price', { type: 'direct' });
|
||||
|
||||
// Set up user balance and feature pricing
|
||||
arbiter.addRelation('user:buyer', 'primary_account', 'account:primary');
|
||||
arbiter.addRelation('account:primary', 'account_balance', 'currency:usd', { value: 1000 });
|
||||
arbiter.addRelation('feature:premium', 'feature_plan', 'plan:gold');
|
||||
arbiter.addRelation('plan:gold', 'plan_price', 'currency:usd', { value: 500 });
|
||||
|
||||
// Left operand: User's balance
|
||||
arbiter.setRelationConfig('user_balance_chain', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'primary_account', direction: 'out' },
|
||||
{ relation: 'account_balance', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [0],
|
||||
relations: ['account_balance']
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
// Right operand: Feature's price
|
||||
arbiter.setRelationConfig('feature_price_chain', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'feature_plan', direction: 'out' },
|
||||
{ relation: 'plan_price', direction: 'out' }
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [1],
|
||||
relations: ['plan_price']
|
||||
},
|
||||
valueAggregation: 'min'
|
||||
});
|
||||
|
||||
const balanceResult = arbiter.check('user:buyer', 'user_balance_chain', 'currency:usd');
|
||||
const priceResult = arbiter.check('feature:premium', 'feature_price_chain', 'currency:usd');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('RelationalComparatorRule integration test results:', {
|
||||
balance: { allow: balanceResult.possibility, reason: balanceResult.reason },
|
||||
price: { allow: priceResult.possibility, reason: priceResult.reason }
|
||||
});
|
||||
|
||||
// Both should successfully reach targets
|
||||
assert.equal(balanceResult.possibility, 1);
|
||||
assert.equal(priceResult.possibility, 1);
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ RELATIONAL COMPARATOR INTEGRATION: Both operand chains work correctly');
|
||||
if (process.env.TEST_DEBUG === '1') console.log(' This validates that RelationalComparatorRule can extract values for comparison');
|
||||
});
|
||||
|
||||
it('handles complex multi-step value collection paths', () => {
|
||||
const arbiter = setupChainRuleTestGraph();
|
||||
|
||||
// Create a complex 4-step chain for comprehensive testing
|
||||
arbiter.addNode('company:tech', 'company');
|
||||
arbiter.addNode('division:cloud', 'division');
|
||||
arbiter.addNode('team:backend', 'team');
|
||||
arbiter.addNode('project:api', 'project');
|
||||
arbiter.addNode('resource:compute', 'resource');
|
||||
|
||||
arbiter.setRelationConfig('has_division', { type: 'direct' });
|
||||
arbiter.setRelationConfig('has_team', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owns_project', { type: 'direct' });
|
||||
arbiter.setRelationConfig('requires_resource', { type: 'direct' });
|
||||
arbiter.setRelationConfig('resource_cost', { type: 'direct' });
|
||||
|
||||
// Set up 4-step chain: company → division → team → project → resource
|
||||
arbiter.addRelation('company:tech', 'has_division', 'division:cloud');
|
||||
arbiter.addRelation('division:cloud', 'has_team', 'team:backend');
|
||||
arbiter.addRelation('team:backend', 'owns_project', 'project:api');
|
||||
arbiter.addRelation('project:api', 'requires_resource', 'resource:compute');
|
||||
arbiter.addRelation('project:api', 'resource_cost', 'resource:compute', { value: 5000 });
|
||||
|
||||
arbiter.setRelationConfig('company_resource_costs', {
|
||||
type: 'chain',
|
||||
steps: [
|
||||
{ relation: 'has_division', direction: 'out' }, // company → division
|
||||
{ relation: 'has_team', direction: 'out' }, // division → team
|
||||
{ relation: 'owns_project', direction: 'out' }, // team → project
|
||||
{ relation: 'requires_resource', direction: 'out' } // project → resource
|
||||
],
|
||||
collectValues: true,
|
||||
valueFilters: {
|
||||
steps: [2], // Collect from step 2 (projects)
|
||||
relations: ['resource_cost']
|
||||
},
|
||||
valueAggregation: 'sum'
|
||||
});
|
||||
|
||||
const result = arbiter.check('company:tech', 'company_resource_costs', 'resource:compute');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('Complex multi-step test result:', {
|
||||
possibility: result.possibility,
|
||||
reason: result.reason
|
||||
});
|
||||
|
||||
// Should successfully traverse the entire 4-step chain
|
||||
assert.equal(result.possibility, 1);
|
||||
assert.equal(result.reason, 'allow_rule_matched');
|
||||
|
||||
if (process.env.TEST_DEBUG === '1') console.log('✅ COMPLEX MULTI-STEP TEST: Verifying deep chain value collection');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user