Files
core/tests/rules/relational-comparator-rule.test.js
John Dvorak 717ae1031e 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.
2026-07-31 13:44:06 -07:00

559 lines
20 KiB
JavaScript

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}`);
});
});
});