Files
evidence-dsl/tests/DSLRuntimeExt.test.js
T

148 lines
6.3 KiB
JavaScript
Raw Permalink Normal View History

/**
* tests/DSLRuntimeExt.test.js — extended DSLRuntime capabilities:
* - schema introspection (getSchema)
* - per-relation provider registration (registerFact/unregisterFact)
* - provider merging (registered + per-check overrides)
* - bounded fixed-point provider retrieval loop (edges satisfy other facts)
* - require() throw-on-deny
* - removal passthroughs and fact-relation check validation
* - timestamp/duration field typing
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '@arbiter/core';
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
const BASE_DSL = `
definition Employee { id: string level: number active: boolean }
definition Doc { id: string created: timestamp }
fact *owns(user: Employee, doc: Doc)
fact *banned(user: Employee)
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) }
`;
function makeRuntime() {
return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-ext');
}
describe('DSLRuntime extended', () => {
it('exposes a serializable schema snapshot', () => {
const rt = makeRuntime();
const schema = rt.getSchema();
assert.ok(Array.isArray(schema.types));
const employee = schema.types.find(t => t.name === 'Employee');
assert.ok(employee);
assert.ok(employee.fields.some(f => f.name === 'level' && f.type === 'number'));
const owns = schema.facts.find(f => f.name === 'owns');
assert.equal(owns.injectable, true);
assert.equal(owns.params[1].type, 'Doc');
const can_open = schema.evidence.find(e => e.name === 'can_open');
assert.ok(can_open.dependsOn.includes('owns'));
assert.deepEqual(schema.providers, []);
assert.ok(rt.relationNames().includes('owns') && rt.relationNames().includes('can_read'));
});
it('registers, lists, and unregisters per-relation providers', () => {
const rt = makeRuntime();
rt.registerFact('owns', async () => 0.8);
assert.deepEqual(rt.registeredFacts(), ['owns']);
rt.registerFact('banned', async () => 0);
assert.deepEqual(rt.registeredFacts().sort(), ['banned', 'owns']);
rt.unregisterFact('banned');
assert.deepEqual(rt.registeredFacts(), ['owns']);
assert.throws(() => rt.registerFact('owns', 'not a function'), /must be a function/);
});
it('merges registered providers with per-check overrides', async () => {
const rt = makeRuntime();
rt.registerFact('owns', async () => 0.5);
rt.registerFact('banned', async () => 0);
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
// registered owns (0.5) wins over nothing; per-check banned overrides
const res = await rt.check('u:1', 'can_open', 'doc:9', {
factProviders: { banned: async () => 0 }
});
assert.equal(res.possibility, 0.5);
assert.deepEqual(res.providedFacts.sort(), ['banned', 'owns']);
});
it('runs providers to a fixed point when edges satisfy other required facts', async () => {
// can_open needs owns (injectable). A registered owns provider returns an
// edge for a DIFFERENT injectable fact that can_open also requires via
// composition — here we add a transitive requirement to prove the loop.
const dsl = `
definition Employee { id: string }
definition Doc { id: string }
fact *owns(user: Employee, doc: Doc)
fact *granted(user: Employee, doc: Doc)
evidence base_read(user: Employee, doc: Doc) { owns(user, doc) }
evidence can_open(user: Employee, doc: Doc) { WHEN base_read(user, doc) UNLESS granted(user, doc) }
`;
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-loop');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
let ownsCalls = 0;
let grantedCalls = 0;
rt.registerFact('owns', async () => {
ownsCalls++;
// First round the owns provider also supplies the granted edge (a
// fixed-point dependency: granted needs owns to have been retrieved).
return [
{ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 0.9 },
{ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: 0 }
];
});
rt.registerFact('granted', async () => { grantedCalls++; return 0; });
const res = await rt.check('u:1', 'can_open', 'doc:9', { maxProviderRounds: 3 });
assert.equal(res.possibility, 0.9);
// granted was satisfied by the owns provider's extra edge, so its own
// provider was never needed in a later round.
assert.equal(grantedCalls, 0);
assert.ok(ownsCalls >= 1);
assert.deepEqual(res.providedFacts, ['owns']);
assert.deepEqual(res.missingFacts, []);
});
it('require() throws on denial and returns the result on grant', async () => {
const rt = makeRuntime();
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('owns', async () => 0.9);
const ok = await rt.require('u:1', 'can_read', 'doc:9');
assert.equal(ok.possibility, 0.9);
rt.registerFact('owns', async () => 0);
await assert.rejects(
() => rt.require('u:1', 'can_read', 'doc:9'),
(err) => err.result && err.result.possibility === 0 && /denied/.test(err.message)
);
});
it('passes through node/relation removal', () => {
const rt = makeRuntime();
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 });
rt.removeRelation('u:1', 'owns', 'doc:9');
assert.equal(rt.arbiter.check('u:1', 'owns', 'doc:9').possibility, 0);
rt.removeNode('u:1');
assert.equal(rt.arbiter.nodeIdByKey.has('u:1'), false);
});
it('validates fact-relation check endpoints like evidence', async () => {
const rt = makeRuntime();
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
// can_read is evidence; owns is a fact — checking a fact still validates.
await assert.rejects(() => rt.check('u:1', 'owns', 'u:1', {}), /expected 'Doc'/);
});
it('accepts timestamp field values and rejects mistyped ones', () => {
const rt = makeRuntime();
rt.addNode('doc:9', 'Doc', { created: 1720000000000 });
rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' });
assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/);
});
});