import { RelationalComparatorRule } from '../../src/authorization/rules/RelationalComparatorRule.js'; import { Arbiter } from '../../src/core/Arbiter.js'; import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js'; import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; describe('RelationalComparatorRule - Real-world Financial & Authorization Scenarios', () => { let comparatorRule; let arbiter; let ruleEvaluator; // Helper function to evaluate rules function evaluateRule(userKey, objectKey, rule, visited = {}, currentRelation = null, options = {}) { const userId = arbiter.resolveNodeId(userKey); const objectId = arbiter.resolveNodeId(objectKey); const mergedOptions = { collectValues: true, includeMeta: true, ...options }; return comparatorRule._evaluateRule(userId, userKey, objectId, objectKey, rule, visited, currentRelation, mergedOptions); } beforeEach(() => { // Create fresh arbiter for each test arbiter = new Arbiter({ embeddingDimensions: 256 }); ruleEvaluator = new RuleEvaluator(arbiter); comparatorRule = new RelationalComparatorRule(arbiter, ruleEvaluator); // Set up realistic entities arbiter.addNode('user:alice', 'user'); arbiter.addNode('user:bob', 'user'); arbiter.addNode('user:charlie', 'user'); arbiter.addNode('team:engineering', 'team'); arbiter.addNode('team:marketing', 'team'); arbiter.addNode('account:alice_checking', 'account'); arbiter.addNode('account:alice_savings', 'account'); arbiter.addNode('account:team_budget', 'account'); arbiter.addNode('feature:premium', 'feature'); arbiter.addNode('feature:basic', 'feature'); arbiter.addNode('transaction:large_purchase', 'transaction'); // Configure relations arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.setRelationConfig('has_price', { type: 'direct' }); arbiter.setRelationConfig('member_of', { type: 'direct' }); arbiter.setRelationConfig('has_reputation', { type: 'direct' }); arbiter.setRelationConfig('has_risk_score', { type: 'direct' }); arbiter.setRelationConfig('has_amount', { type: 'direct' }); arbiter.setRelationConfig('has_budget', { type: 'direct' }); }); describe('Balance Inquiries - Can User Afford Feature?', () => { it('allows access when user balance exceeds feature price', () => { // Alice has $1000, premium feature costs $800 arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.ok(result.possibility > 0.9, `Expected high possibility, got ${result.possibility}`); assert.strictEqual(result.reason, 'values_compared_comparison_true'); }); it('denies access when user balance is insufficient', () => { // Bob has $500, premium feature costs $800 arbiter.addRelation('user:bob', 'has_balance', 'feature:premium', { value: 500, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:bob', 'feature:premium', rule); assert.ok(result.possibility < 0.1, `Expected low possibility, got ${result.possibility}`); assert.strictEqual(result.reason, 'values_compared_comparison_false'); }); }); describe('Data Freshness - No Decay', () => { it('does not reduce confidence for stale balance data', () => { const staleTime = Date.now() - (60 * 60 * 1000); arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: staleTime }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.ok(result.possibility > 0.9, `Expected high possibility without decay, got ${result.possibility}`); }); it('treats very stale data the same as fresh data', () => { const veryStaleTime = Date.now() - (7 * 24 * 60 * 60 * 1000); arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: veryStaleTime }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true, ttl: 14 * 24 * 60 * 60 * 1000 }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.ok(result.possibility > 0.9, `Expected high possibility without decay, got ${result.possibility}`); }); }); describe('Reputation and Risk Scoring', () => { it('evaluates user reputation against threshold', () => { // Alice has high reputation (0.9), we want to check if it's >= 0.8 arbiter.addRelation('user:alice', 'has_reputation', 'user:alice', { value: 0.9, possibility: 1.0, changed_last_at: Date.now() }); // Create a threshold entity with the minimum required reputation arbiter.addNode('threshold:reputation', 'threshold'); arbiter.addRelation('threshold:reputation', 'has_value', 'threshold:reputation', { value: 0.8, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>=', left: { rule: { type: 'direct', relation: 'has_reputation' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_value' }, extractValue: true } }; const result = evaluateRule('user:alice', 'threshold:reputation', rule); assert.ok(result.possibility > 0.5, `Expected reasonable possibility for reputation check, got ${result.possibility}`); }); it('evaluates risk score with margin of safety', () => { // Bob has risk score 0.3, but we want 10% margin of safety arbiter.addRelation('user:bob', 'has_risk_score', 'user:bob', { value: 0.3, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '<', left: { rule: { type: 'direct', relation: 'has_risk_score' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_risk_score' }, extractValue: false // Use rule possibility as value (0.4 threshold) }, marginOfSafety: 1.1 // 10% margin }; const result = evaluateRule('user:bob', 'user:bob', rule); // 0.3 < (0.4 * 1.1) = 0.44, so should pass assert.ok(result.possibility > 0.8, `Expected high possibility with margin of safety, got ${result.possibility}`); }); }); describe('Team Budget and Membership', () => { it('checks if team has sufficient budget for transaction', () => { // Engineering team has $5000 budget, transaction costs $3000 arbiter.addRelation('team:engineering', 'has_budget', 'transaction:large_purchase', { value: 5000, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('transaction:large_purchase', 'has_amount', 'transaction:large_purchase', { value: 3000, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>=', left: { rule: { type: 'direct', relation: 'has_budget' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_amount' }, extractValue: true } }; const result = evaluateRule('team:engineering', 'transaction:large_purchase', rule); assert.ok(result.possibility > 0.9, `Expected high possibility for team budget check, got ${result.possibility}`); }); it('handles team membership with nested rules', () => { // Alice is member of engineering team, team has budget arbiter.addRelation('user:alice', 'member_of', 'team:engineering', { value: 1.0, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('team:engineering', 'has_budget', 'transaction:large_purchase', { value: 5000, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('transaction:large_purchase', 'has_amount', 'transaction:large_purchase', { value: 3000, possibility: 1.0, changed_last_at: Date.now() }); // This would typically be handled by a chain rule, but we can test the comparator part const rule = { type: 'relational_comparator', comparator: '>=', left: { rule: { type: 'direct', relation: 'has_budget' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_amount' }, extractValue: true } }; const result = evaluateRule('team:engineering', 'transaction:large_purchase', rule); assert.ok(result.possibility > 0.9, `Expected high possibility for team budget, got ${result.possibility}`); }); }); describe('OWA Aggregation of Multiple Values', () => { it('aggregates multiple account balances using max', () => { arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); // Alice has multiple balances, we want the max arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 500, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 1500, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'max' } }, extractValue: true, aggregator: 'max' // Use max of all balances }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); // Should use max balance (1500) > price (1000) assert.ok(result.possibility > 0.9, `Expected high possibility using max aggregation, got ${result.possibility}`); }); it('aggregates multiple account balances using sum', () => { arbiter.setRelationConfig('has_balance_primary', { type: 'direct' }); arbiter.setRelationConfig('has_balance_secondary', { type: 'direct' }); // Alice has multiple balances, we want total available funds arbiter.addRelation('user:alice', 'has_balance_primary', 'feature:premium', { value: 500, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('user:alice', 'has_balance_secondary', 'feature:premium', { value: 300, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { union: { rules: [ { type: 'direct', relation: 'has_balance_primary' }, { type: 'direct', relation: 'has_balance_secondary' } ], aggregator: 'sum' } }, extractValue: true, aggregator: 'sum' // Use sum of all balances }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); // Should use sum balance (800) < price (1000), so should fail assert.ok(result.possibility < 0.1, `Expected low possibility using sum aggregation, got ${result.possibility}`); }); }); describe('Interval Blurring for Uncertain Values', () => { it('keeps crisp comparisons without blur', () => { const staleTime = Date.now() - (2 * 60 * 60 * 1000); arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1000, possibility: 0.7, // Reduced confidence due to uncertainty changed_last_at: staleTime }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.equal(result.possibility, 1); }); }); describe('Edge Cases and Error Handling', () => { it('handles missing values gracefully', () => { // Alice has no balance relation arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }, fallbackBehavior: 'deny' }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.strictEqual(result.possibility, 0, 'Expected 0 possibility for missing balance'); assert.strictEqual(result.reason, 'left_operand_missing_comparison_false'); }); it('handles both operands missing', () => { const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { rule: { type: 'direct', relation: 'has_price' }, extractValue: true }, fallbackBehavior: 'deny' }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.strictEqual(result.possibility, 0, 'Expected 0 possibility for missing operands'); assert.strictEqual(result.reason, 'both_operands_missing_fallback_deny'); }); it('handles inequality with missing operand', () => { // Missing balance but checking != (should return true) arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '!=', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule); assert.strictEqual(result.possibility, 1.0, 'Expected 1.0 possibility for inequality with missing operand'); assert.strictEqual(result.reason, 'left_operand_missing_inequality_true'); }); }); describe('Performance and Optimization', () => { it('supports fast path optimization', () => { // Simple case that should use fast path arbiter.addRelation('user:alice', 'has_balance', 'feature:premium', { value: 1000, possibility: 1.0, changed_last_at: Date.now() }); arbiter.addRelation('feature:premium', 'has_price', 'feature:premium', { value: 800, possibility: 1.0, changed_last_at: Date.now() }); const rule = { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }; const result = evaluateRule('user:alice', 'feature:premium', rule, {}, null, { fastPath: true }); assert.ok(result.possibility > 0.9, `Expected high possibility with fast path, got ${result.possibility}`); }); }); });