Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: CI
on: [push, pull_request]
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
Expand All @@ -20,3 +24,26 @@ jobs:
- run: npm run intent:validate
- run: npm run intent:test
- run: npm pack

opa:
name: OPA / Rego
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- uses: open-policy-agent/setup-opa@v2
with:
version: 1.4.2
- run: npm ci
- run: npm run opa:generate
- name: Verify generated Rego is committed
run: |
git diff --exit-code -- opa/policies
test -z "$(git ls-files --others --exclude-standard opa/policies)"
- run: npm run opa:check
- run: npm run opa:test
- run: npm run opa:server:test
- run: npm run opa:conformance
23 changes: 23 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,26 @@ prd conformance decision DEC-001 --runtime opa
O adapter OPA compila somente o subconjunto já suportado: tabela `UNIQUE`,
condições de igualdade, wildcard por condição omitida e saída escalar. Ele não
expande o perfil DMN. O executável `opa` é opcional e não é baixado pelo pacote.

### Suíte OPA local e no GitHub Actions

A política versionada em `opa/policies/dec_001.rego` é gerada a partir de
`examples/transfer/product/decisions/DEC-001.yaml`. Para reproduzir a suíte do
CI localmente, instale o executável `opa` no `PATH` e execute:

```sh
npm ci
npm run opa:generate
npm run opa:check
npm run opa:test
npm run opa:server:test
npm run opa:conformance
```

`opa:check` valida sintaxe estrita e formatação. `opa:test` executa os testes
unitários Rego. `opa:server:test` inicia
temporariamente `opa run --server`, aguarda o health check e valida os quatro
casos pelo endpoint REST `/v1/data/prd/decision/dec_001/result`.
`opa:conformance` exercita o adapter do CLI contra a mesma decisão. O job
`OPA / Rego` também regenera a política e falha quando o arquivo versionado está
desatualizado em relação ao YAML.
20 changes: 20 additions & 0 deletions opa/policies/dec_001.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package prd.decision.dec_001

matches contains {"value": "CONTA_INATIVA", "ruleId": "DROW-001"} if {
input.contaAtiva == false
}

matches contains {"value": "SALDO_INSUFICIENTE", "ruleId": "DROW-002"} if {
input.contaAtiva == true
input.saldoSuficiente == false
}

matches contains {"value": "ELEGIVEL", "ruleId": "DROW-003"} if {
input.contaAtiva == true
input.saldoSuficiente == true
}

result := item if {
count(matches) == 1
item := matches[_]
}
37 changes: 37 additions & 0 deletions opa/tests/dec_001_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package prd.decision.dec_001_test

import data.prd.decision.dec_001.result

cases := [
{
"id": "CASE-001",
"input": {"contaAtiva": true, "saldoSuficiente": true},
"expected": {"value": "ELEGIVEL", "ruleId": "DROW-003"},
},
{
"id": "CASE-002",
"input": {"contaAtiva": false, "saldoSuficiente": true},
"expected": {"value": "CONTA_INATIVA", "ruleId": "DROW-001"},
},
{
"id": "CASE-003",
"input": {"contaAtiva": true, "saldoSuficiente": false},
"expected": {"value": "SALDO_INSUFICIENTE", "ruleId": "DROW-002"},
},
{
"id": "CASE-004",
"input": {"contaAtiva": false, "saldoSuficiente": false},
"expected": {"value": "CONTA_INATIVA", "ruleId": "DROW-001"},
},
]

test_declared_decision_cases if {
every case in cases {
actual := result with input as case.input
actual == case.expected
}
}

test_incomplete_input_is_undefined if {
not result with input as {"contaAtiva": true}
}
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
"demo": "npm run build && node scripts/demo.mjs",
"prepack": "npm run build",
"intent:validate": "node dist/interfaces/cli/main.js -C examples/transfer validate --strict",
"intent:test": "node dist/interfaces/cli/main.js -C examples/transfer test --require-tests"
"intent:test": "node dist/interfaces/cli/main.js -C examples/transfer test --require-tests",
"opa:generate": "npm run build && node scripts/generate-opa-policies.mjs",
"opa:check": "opa fmt --list --fail opa/policies opa/tests && opa check --strict opa/policies opa/tests",
"opa:test": "opa test opa/policies opa/tests --verbose",
"opa:server:test": "node scripts/test-opa-server.mjs",
"opa:conformance": "node dist/interfaces/cli/main.js -C examples/transfer conformance decision DEC-001 --runtime opa"
},
"dependencies": {
"commander": "14.0.2", "yaml": "2.9.1", "ajv": "8.17.1",
Expand Down
25 changes: 25 additions & 0 deletions scripts/generate-opa-policies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'yaml';
import { generateDecisionRego, opaPackageName } from '../dist/infrastructure/opa/rego-generator.js';

const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const sourcePath = resolve(
repositoryRoot,
process.argv[2] ?? 'examples/transfer/product/decisions/DEC-001.yaml',
);
const targetDirectory = resolve(repositoryRoot, process.argv[3] ?? 'opa/policies');
const artifact = parse(readFileSync(sourcePath, 'utf8'));

if (artifact?.kind !== 'Decision' || typeof artifact.metadata?.id !== 'string' || !artifact.spec) {
throw new Error(`${sourcePath} is not a Decision artifact.`);
}

const packageName = opaPackageName(artifact.metadata.id);
const fileName = `${packageName.split('.').at(-1)}.rego`;
const targetPath = resolve(targetDirectory, fileName);

mkdirSync(targetDirectory, { recursive: true });
writeFileSync(targetPath, generateDecisionRego(artifact.metadata.id, artifact.spec), 'utf8');
process.stdout.write(`Generated ${targetPath}\n`);
75 changes: 75 additions & 0 deletions scripts/test-opa-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';

const binary = process.env.PRD_OPA_BINARY || 'opa';
const host = process.env.OPA_TEST_HOST || '127.0.0.1';
const port = Number(process.env.OPA_TEST_PORT || 8181);
const baseUrl = `http://${host}:${port}`;
const policyPath = fileURLToPath(new URL('../opa/policies', import.meta.url));
const server = spawn(binary, ['run', '--server', `--addr=${host}:${port}`, policyPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});

let output = '';
server.stdout.setEncoding('utf8').on('data', chunk => { output += chunk; });
server.stderr.setEncoding('utf8').on('data', chunk => { output += chunk; });

async function waitUntilReady() {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (server.exitCode !== null) throw new Error(`OPA server stopped unexpectedly.\n${output}`);
try {
const response = await fetch(`${baseUrl}/health?bundles`);
if (response.ok) return;
} catch {
// The listener may not be ready yet.
}
await delay(100);
}
throw new Error(`OPA server did not become ready at ${baseUrl}.\n${output}`);
}

const cases = [
{
id: 'CASE-001',
input: { contaAtiva: true, saldoSuficiente: true },
expected: { value: 'ELEGIVEL', ruleId: 'DROW-003' },
},
{
id: 'CASE-002',
input: { contaAtiva: false, saldoSuficiente: true },
expected: { value: 'CONTA_INATIVA', ruleId: 'DROW-001' },
},
{
id: 'CASE-003',
input: { contaAtiva: true, saldoSuficiente: false },
expected: { value: 'SALDO_INSUFICIENTE', ruleId: 'DROW-002' },
},
{
id: 'CASE-004',
input: { contaAtiva: false, saldoSuficiente: false },
expected: { value: 'CONTA_INATIVA', ruleId: 'DROW-001' },
},
];

try {
await waitUntilReady();
for (const testCase of cases) {
const response = await fetch(`${baseUrl}/v1/data/prd/decision/dec_001/result`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ input: testCase.input }),
});
assert.equal(response.status, 200, `${testCase.id}: unexpected HTTP status`);
const document = await response.json();
assert.deepEqual(document.result, testCase.expected, `${testCase.id}: divergent decision`);
}
process.stdout.write(`OPA server: ${cases.length} decision cases passed.\n`);
} finally {
server.kill('SIGTERM');
await Promise.race([
new Promise(resolveExit => server.once('exit', resolveExit)),
delay(2_000, undefined, { ref: false }).then(() => server.kill('SIGKILL')),
]);
}
32 changes: 28 additions & 4 deletions src/infrastructure/opa/rego-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ function scalar(value: Scalar): string {
return JSON.stringify(value);
}

const regoKeywords = new Set([
'as',
'contains',
'default',
'else',
'every',
'false',
'if',
'import',
'in',
'not',
'null',
'package',
'some',
'true',
'with',
]);

function inputReference(name: string): string {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !regoKeywords.has(name)
? `input.${name}`
: `input[${JSON.stringify(name)}]`;
}

export function opaPackageName(decisionId: string): string {
const safe = decisionId.toLowerCase().replace(/[^a-z0-9_]/g, '_');
return `prd.decision.${safe}`;
Expand All @@ -13,9 +37,9 @@ export function generateDecisionRego(decisionId: string, spec: DecisionSpec): st
const packageName = opaPackageName(decisionId);
const rules = spec.rules.map(rule => {
const conditions = Object.entries(rule.when)
.map(([name, value]) => ` input[${JSON.stringify(name)}] == ${scalar(value)}`)
.map(([name, value]) => `\t${inputReference(name)} == ${scalar(value)}`)
.join('\n');
const body = conditions || ' true';
const body = conditions || '\ttrue';
return `matches contains {"value": ${scalar(rule.then)}, "ruleId": ${JSON.stringify(rule.id)}} if {\n${body}\n}`;
});

Expand All @@ -24,8 +48,8 @@ export function generateDecisionRego(decisionId: string, spec: DecisionSpec): st
'',
...rules.flatMap(rule => [rule, '']),
'result := item if {',
' count(matches) == 1',
' item := matches[_]',
'\tcount(matches) == 1',
'\titem := matches[_]',
'}',
'',
].join('\n');
Expand Down
15 changes: 14 additions & 1 deletion tests/conformance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,20 @@ test('rego generator preserves decision ids, values and wildcard rows', () => {
const rego = generateDecisionRego('DEC-TEST', spec);
assert.equal(opaPackageName('DEC-TEST'), 'prd.decision.dec_test');
assert.match(rego, /package prd\.decision\.dec_test/);
assert.match(rego, /input\["active"\] == true/);
assert.match(rego, /input\.active == true/);
assert.match(rego, /"ruleId": "allow"/);
assert.match(rego, /count\(matches\) == 1/);
});

test('rego generator uses bracket notation for reserved Rego keywords', () => {
const keywordSpec = {
...spec,
inputs: [{ name: 'if', type: 'boolean' }],
rules: [{ id: 'keyword', when: { if: true }, then: 'ALLOW' }],
cases: [{ id: 'C-KEYWORD', input: { if: true }, expected: 'ALLOW' }],
};

const rego = generateDecisionRego('DEC-KEYWORD', keywordSpec);
assert.match(rego, /input\["if"\] == true/);
assert.doesNotMatch(rego, /input\.if/);
});
Loading