0c2ddc282b
The legacy test files (Definition/Evidence/Expression/Fact/Integration/ Measure) predate the DSL's validation rules: reserved built-in types (User/Account/Device/AuthSession) and the boolean/predicate-call requirements on evidence statements. Fixed the fixtures, not the engine: - reserved renames: User->Employee, Account->Tenant (Device/AuthSession were only legally referenced) - expression fixtures: comparators now use predicate calls on both sides (fact score(value: number) + score(1 + 2 * 3) > score(0)); measure bodies carry the original arbitrary expressions (validated, not rule-generated) - grammar-shape fixes: comma-separated fusion/aggregate lists, PROVIDES X (no array returns), predicate-call evidence statements All 54 tests across the 8 files pass.
289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
import { describe, test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DSLCompiler } from '../src/DSLCompiler.js';
|
|
|
|
function createMockArbiter() {
|
|
const relationConfigs = new Map();
|
|
return {
|
|
relationConfigs,
|
|
setRelationConfig(relation, config) {
|
|
relationConfigs.set(relation, config);
|
|
}
|
|
};
|
|
}
|
|
|
|
// The compiler validates evidence bodies as generated rules, which only accept
|
|
// predicate-call forms. Pure expression forms (booleans, arithmetic, within,
|
|
// attribute access, && / || chains) still parse and validate inside measure
|
|
// bodies, which are checked but not rule-generated. So expression-precedence
|
|
// fixtures use measures, while comparator fixtures use predicate calls on both
|
|
// sides of the operator.
|
|
const SCORE_FACT = `
|
|
fact score(value: number)
|
|
`;
|
|
|
|
const EMPLOYEE_FIELDS = `
|
|
definition Employee {
|
|
role: string
|
|
isActive: boolean
|
|
isSuspended: boolean
|
|
isBlacklisted: boolean
|
|
isTrusted: boolean
|
|
lastActive: timestamp
|
|
lastLogin: timestamp
|
|
createdAt: timestamp
|
|
lastActivity: timestamp
|
|
hasEmergencyAccess: boolean
|
|
balance: number
|
|
score: number
|
|
profile: Profile
|
|
permissions: Permission[]
|
|
}
|
|
|
|
definition Profile {
|
|
name: string
|
|
permissions: Permission[]
|
|
}
|
|
|
|
definition Permission {
|
|
name: string
|
|
}
|
|
|
|
definition Resource {
|
|
name: string
|
|
}
|
|
|
|
definition Document {
|
|
name: string
|
|
}
|
|
|
|
fact hasRole(user: any, role: string)
|
|
fact isMember(user: any, group: any)
|
|
fact hasPermission(user: any, resource: any, action: string)
|
|
fact isActive(user: any)
|
|
`;
|
|
|
|
describe('Expression Parsing', () => {
|
|
const arbiter = createMockArbiter();
|
|
const compiler = new DSLCompiler(arbiter);
|
|
|
|
test('Arithmetic operator precedence', () => {
|
|
const testCases = [
|
|
{
|
|
input: 'score(1 + 2 * 3) > score(0)',
|
|
expected: 'Should evaluate as 1 + (2 * 3) = 7',
|
|
description: 'Multiplication before addition'
|
|
},
|
|
{
|
|
input: 'score(10 - 3 * 2) > score(0)',
|
|
expected: 'Should evaluate as 10 - (3 * 2) = 4',
|
|
description: 'Multiplication before subtraction'
|
|
},
|
|
{
|
|
input: 'score(8 / 2 * 4) > score(0)',
|
|
expected: 'Should evaluate as (8 / 2) * 4 = 16',
|
|
description: 'Left-associative division and multiplication'
|
|
},
|
|
{
|
|
input: 'score(2 + 3 * 4 - 5) > score(0)',
|
|
expected: 'Should evaluate as 2 + (3 * 4) - 5 = 9',
|
|
description: 'Mixed arithmetic with correct precedence'
|
|
},
|
|
{
|
|
input: 'score((1 + 2) * 3) > score(0)',
|
|
expected: 'Should evaluate as (1 + 2) * 3 = 9',
|
|
description: 'Parentheses override precedence'
|
|
}
|
|
];
|
|
|
|
testCases.forEach(({ input, expected, description }) => {
|
|
const dsl = SCORE_FACT + `evidence test() { ${input} }`;
|
|
const result = compiler.compile(dsl, `test-arithmetic-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Logical operator precedence', () => {
|
|
const testCases = [
|
|
{
|
|
input: 'true && false || true',
|
|
expected: 'Should evaluate as (true && false) || true = true',
|
|
description: 'AND before OR'
|
|
},
|
|
{
|
|
input: 'false || true && false',
|
|
expected: 'Should evaluate as false || (true && false) = false',
|
|
description: 'AND before OR (alternative)'
|
|
},
|
|
{
|
|
input: 'NOT true && false',
|
|
expected: 'Should evaluate as (NOT true) && false = false',
|
|
description: 'NOT before AND'
|
|
},
|
|
{
|
|
input: 'true && NOT false',
|
|
expected: 'Should evaluate as true && (NOT false) = true',
|
|
description: 'NOT before AND (alternative)'
|
|
},
|
|
{
|
|
input: '(true || false) && true',
|
|
expected: 'Should evaluate as (true || false) && true = true',
|
|
description: 'Parentheses override logical precedence'
|
|
}
|
|
];
|
|
|
|
testCases.forEach(({ input, expected, description }) => {
|
|
const dsl = `measure test() { ${input} } PROVIDES boolean`;
|
|
const result = compiler.compile(dsl, `test-logical-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Comparison operators', () => {
|
|
const testCases = [
|
|
{ input: 'score(1) == score(1)', description: 'Equality comparison', measure: false },
|
|
{ input: 'score(1) != score(2)', description: 'Inequality comparison', measure: false },
|
|
{ input: 'score(5) > score(3)', description: 'Greater than', measure: false },
|
|
{ input: 'score(3) < score(5)', description: 'Less than', measure: false },
|
|
{ input: 'score(4) >= score(4)', description: 'Greater than or equal', measure: false },
|
|
{ input: 'score(4) <= score(4)', description: 'Less than or equal', measure: false },
|
|
{ input: 'score(1) == score(1) && score(2) > score(1)', description: 'Comparison with logical operators', measure: true },
|
|
{ input: 'score(1 + 2) == score(3)', description: 'Arithmetic in comparison', measure: false }
|
|
];
|
|
|
|
testCases.forEach(({ input, description, measure }) => {
|
|
const body = `test() { ${input} }`;
|
|
const dsl = SCORE_FACT + (measure ? `measure ${body} PROVIDES boolean` : `evidence ${body}`);
|
|
const result = compiler.compile(dsl, `test-comparison-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Temporal expressions', () => {
|
|
const testCases = [
|
|
{ input: 'user.lastActive within 1h', description: 'Temporal within expression' },
|
|
{ input: 'user.lastLogin within 24h', description: 'Temporal within with hours' },
|
|
{ input: 'user.createdAt within 7d', description: 'Temporal within with days' },
|
|
{ input: 'user.lastActivity within 1h && user.isActive', description: 'Temporal with logical operators' }
|
|
];
|
|
|
|
testCases.forEach(({ input, description }) => {
|
|
const dsl = EMPLOYEE_FIELDS + `measure test(user: Employee) { ${input} } PROVIDES boolean`;
|
|
const result = compiler.compile(dsl, `test-temporal-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Unary operators', () => {
|
|
const testCases = [
|
|
{ input: 'NOT true', description: 'NOT operator', params: '' },
|
|
{ input: '! false', description: 'Alternative NOT operator', params: '' },
|
|
{ input: 'NOT (true && false)', description: 'NOT with parenthesized expression', params: '' },
|
|
{ input: 'NOT user.isSuspended', description: 'NOT with attribute access', params: 'user: Employee' }
|
|
];
|
|
|
|
testCases.forEach(({ input, description, params }) => {
|
|
const dsl = EMPLOYEE_FIELDS + `measure test(${params}) { ${input} } PROVIDES boolean`;
|
|
const result = compiler.compile(dsl, `test-unary-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Attribute access', () => {
|
|
const testCases = [
|
|
{ input: 'user.role', description: 'Simple attribute access' },
|
|
{ input: 'user.profile.name', description: 'Nested attribute access' },
|
|
{ input: 'user.permissions', description: 'Array access' },
|
|
{ input: 'user.profile.permissions', description: 'Nested attribute with array access' },
|
|
{ input: 'user.isActive && user.role == "admin"', description: 'Attribute access in logical expression' }
|
|
];
|
|
|
|
testCases.forEach(({ input, description }) => {
|
|
const dsl = EMPLOYEE_FIELDS + `measure test(user: Employee) { ${input} } PROVIDES boolean`;
|
|
const result = compiler.compile(dsl, `test-attribute-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Function calls', () => {
|
|
const testCases = [
|
|
{ input: 'hasRole(user, "admin")', description: 'Simple function call', measure: false },
|
|
{ input: 'isMember(user, group)', description: 'Function call with variables', measure: false },
|
|
{ input: 'hasPermission(user, resource, "read")', description: 'Function call with multiple arguments', measure: false },
|
|
{ input: 'hasRole(user, "admin") && isActive(user)', description: 'Multiple function calls', measure: true },
|
|
{ input: 'hasRole(user, user.role)', description: 'Function call with attribute access', measure: false }
|
|
];
|
|
|
|
testCases.forEach(({ input, description, measure }) => {
|
|
const params = 'user: Employee, group: Employee, resource: Resource';
|
|
const body = `test(${params}) { ${input} }`;
|
|
const dsl = EMPLOYEE_FIELDS + (measure ? `measure ${body} PROVIDES boolean` : `evidence ${body}`);
|
|
const result = compiler.compile(dsl, `test-function-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Complex expressions', () => {
|
|
const testCases = [
|
|
{
|
|
input: 'user.isActive && (hasRole(user, "admin") || hasPermission(user, resource, "read"))',
|
|
description: 'Complex logical expression with function calls'
|
|
},
|
|
{
|
|
input: 'user.balance > 100 && user.isActive && NOT user.isSuspended',
|
|
description: 'Multiple conditions with NOT'
|
|
},
|
|
{
|
|
input: 'user.lastActive within 1h && (user.role == "admin" || user.hasEmergencyAccess)',
|
|
description: 'Temporal with logical conditions'
|
|
},
|
|
{
|
|
input: 'hasRole(user, "admin") && user.isActive && NOT (user.isSuspended || user.isBlacklisted)',
|
|
description: 'Complex negation with multiple conditions'
|
|
},
|
|
{
|
|
input: 'user.score > 0.8 && user.isTrusted && user.lastActivity within 24h',
|
|
description: 'Multiple attribute conditions with temporal'
|
|
}
|
|
];
|
|
|
|
testCases.forEach(({ input, description }) => {
|
|
const params = 'user: Employee, resource: Resource, doc: Document';
|
|
const dsl = EMPLOYEE_FIELDS + `measure test(${params}) { ${input} } PROVIDES boolean`;
|
|
const result = compiler.compile(dsl, `test-complex-${Date.now()}`);
|
|
assert.ok(result.success, `${description} should parse successfully`);
|
|
});
|
|
});
|
|
|
|
test('Expression error handling', () => {
|
|
const testCases = [
|
|
{
|
|
input: 'user.role ==',
|
|
description: 'Incomplete comparison should fail'
|
|
},
|
|
{
|
|
input: 'user.role &&',
|
|
description: 'Incomplete logical expression should fail'
|
|
},
|
|
{
|
|
input: 'hasRole(user,)',
|
|
description: 'Function call with missing argument should fail'
|
|
},
|
|
{
|
|
input: 'user.role == "admin" &&',
|
|
description: 'Incomplete logical expression should fail'
|
|
}
|
|
];
|
|
|
|
testCases.forEach(({ input, description }) => {
|
|
try {
|
|
const dsl = `evidence test() { ${input} }`;
|
|
const result = compiler.compile(dsl, `test-error-${Date.now()}`);
|
|
assert.ok(!result.success, `${description} should fail to parse`);
|
|
} catch {
|
|
// Expected to fail
|
|
}
|
|
});
|
|
});
|
|
});
|