From 787ed234775e62ab6a6e9e01f6f99f5eb7738543 Mon Sep 17 00:00:00 2001 From: Cleilson Date: Sun, 4 Oct 2026 00:15:49 -0300 Subject: [PATCH 1/2] ci: add OPA policy test workflow --- .github/workflows/ci.yml | 29 ++++++++- docs/TESTING.md | 23 ++++++++ opa/policies/dec_001.rego | 20 +++++++ opa/tests/dec_001_test.rego | 37 ++++++++++++ package.json | 7 ++- scripts/generate-opa-policies.mjs | 25 ++++++++ scripts/test-opa-server.mjs | 75 ++++++++++++++++++++++++ src/infrastructure/opa/rego-generator.ts | 14 +++-- tests/conformance.test.mjs | 2 +- 9 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 opa/policies/dec_001.rego create mode 100644 opa/tests/dec_001_test.rego create mode 100644 scripts/generate-opa-policies.mjs create mode 100644 scripts/test-opa-server.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9f7943..2b4a282 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: + branches: [main] permissions: contents: read jobs: @@ -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 diff --git a/docs/TESTING.md b/docs/TESTING.md index ad86894..8d18146 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. diff --git a/opa/policies/dec_001.rego b/opa/policies/dec_001.rego new file mode 100644 index 0000000..39ec53d --- /dev/null +++ b/opa/policies/dec_001.rego @@ -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[_] +} diff --git a/opa/tests/dec_001_test.rego b/opa/tests/dec_001_test.rego new file mode 100644 index 0000000..1dbb5d6 --- /dev/null +++ b/opa/tests/dec_001_test.rego @@ -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} +} diff --git a/package.json b/package.json index 234540f..3906557 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-opa-policies.mjs b/scripts/generate-opa-policies.mjs new file mode 100644 index 0000000..d9ec00c --- /dev/null +++ b/scripts/generate-opa-policies.mjs @@ -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`); diff --git a/scripts/test-opa-server.mjs b/scripts/test-opa-server.mjs new file mode 100644 index 0000000..14f0570 --- /dev/null +++ b/scripts/test-opa-server.mjs @@ -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).then(() => server.kill('SIGKILL')), + ]); +} diff --git a/src/infrastructure/opa/rego-generator.ts b/src/infrastructure/opa/rego-generator.ts index 4da220b..4ba6742 100644 --- a/src/infrastructure/opa/rego-generator.ts +++ b/src/infrastructure/opa/rego-generator.ts @@ -4,6 +4,12 @@ function scalar(value: Scalar): string { return JSON.stringify(value); } +function inputReference(name: string): string { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(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}`; @@ -13,9 +19,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}`; }); @@ -24,8 +30,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'); diff --git a/tests/conformance.test.mjs b/tests/conformance.test.mjs index 10f101e..c99a37e 100644 --- a/tests/conformance.test.mjs +++ b/tests/conformance.test.mjs @@ -56,7 +56,7 @@ 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/); }); From f64205c0879a9f0cc2659523369ca03f766c8297 Mon Sep 17 00:00:00 2001 From: Cleilson Date: Sun, 4 Oct 2026 00:34:44 -0300 Subject: [PATCH 2/2] fix: address OPA review feedback --- scripts/test-opa-server.mjs | 2 +- src/infrastructure/opa/rego-generator.ts | 20 +++++++++++++++++++- tests/conformance.test.mjs | 13 +++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/scripts/test-opa-server.mjs b/scripts/test-opa-server.mjs index 14f0570..51a1504 100644 --- a/scripts/test-opa-server.mjs +++ b/scripts/test-opa-server.mjs @@ -70,6 +70,6 @@ try { server.kill('SIGTERM'); await Promise.race([ new Promise(resolveExit => server.once('exit', resolveExit)), - delay(2_000).then(() => server.kill('SIGKILL')), + delay(2_000, undefined, { ref: false }).then(() => server.kill('SIGKILL')), ]); } diff --git a/src/infrastructure/opa/rego-generator.ts b/src/infrastructure/opa/rego-generator.ts index 4ba6742..35ecbd7 100644 --- a/src/infrastructure/opa/rego-generator.ts +++ b/src/infrastructure/opa/rego-generator.ts @@ -4,8 +4,26 @@ 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) + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !regoKeywords.has(name) ? `input.${name}` : `input[${JSON.stringify(name)}]`; } diff --git a/tests/conformance.test.mjs b/tests/conformance.test.mjs index c99a37e..9792e4f 100644 --- a/tests/conformance.test.mjs +++ b/tests/conformance.test.mjs @@ -60,3 +60,16 @@ test('rego generator preserves decision ids, values and wildcard rows', () => { 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/); +});