From 6b380754923eb2e725e1008873ba71d1756afeda Mon Sep 17 00:00:00 2001 From: jesquivels Date: Thu, 29 Oct 2026 19:06:49 -0600 Subject: [PATCH] jjj --- .gitignore | 2 + README.md | 5 + compose.mock.yaml | 36 + demo/providers-mock/.dockerignore | 3 + demo/providers-mock/Dockerfile | 12 + demo/providers-mock/MockServer.java | 126 ++ .../Providers-Mock.postman_collection.json | 820 ++++++++++++ demo/providers-mock/README.md | 139 ++ demo/providers-mock/integration-routes.json | 82 ++ demo/providers-mock/test_mock.py | 112 ++ .../Orquestador-NMP.postman_collection.json | 1173 +++++++++++++++++ examples/postman/README.md | 64 + specs/DECISIONS.md | 16 + specs/IMPLEMENTATION_PLAN.md | 4 + specs/PROGRESS.md | 22 + .../infrastructure/HttpRoutes.java | 13 +- .../IntegrationConfiguration.java | 3 +- .../orchestrator/HttpIntegrationTest.java | 10 + 18 files changed, 2638 insertions(+), 4 deletions(-) create mode 100644 compose.mock.yaml create mode 100644 demo/providers-mock/.dockerignore create mode 100644 demo/providers-mock/Dockerfile create mode 100644 demo/providers-mock/MockServer.java create mode 100644 demo/providers-mock/Providers-Mock.postman_collection.json create mode 100644 demo/providers-mock/README.md create mode 100644 demo/providers-mock/integration-routes.json create mode 100644 demo/providers-mock/test_mock.py create mode 100644 examples/postman/Orquestador-NMP.postman_collection.json create mode 100644 examples/postman/README.md diff --git a/.gitignore b/.gitignore index 573c041..9b5f8bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ *.log /.tools/ __pycache__/ +/mock.crt +/demo/providers-mock/data/ diff --git a/README.md b/README.md index 544597b..0a976ff 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ Incluye la estructura Maven, configuración JWT/PostgreSQL, Docker y salud verif ## Requisitos y ejecución +Para configurar respuestas de proveedores manualmente desde Postman, consultar +[Proveedores mock](demo/providers-mock/README.md). El archivo opcional +`compose.mock.yaml` añade el servicio HTTP de demo en `microsipNet`; el Compose +base conserva el servicio conectado a PostgreSQL externo. + - Docker con contenedores Linux y Compose v2. La imagen compila con JDK 21 y Maven Wrapper; no requiere Java/Maven en el anfitrión. - PostgreSQL externo con base y usuario existentes, accesible desde Docker. El entorno actual usa PostgreSQL 9.6.2 en server02:30432 mediante el perfil postgres96; ver ADR-006. - Un issuer JWT con JWKS y audiencia; se conserva la autenticación. diff --git a/compose.mock.yaml b/compose.mock.yaml new file mode 100644 index 0000000..c21eb3a --- /dev/null +++ b/compose.mock.yaml @@ -0,0 +1,36 @@ +# Optional demo only: docker compose -f compose.yaml -f compose.mock.yaml ... +services: + providers-mock: + build: + context: ./demo/providers-mock + image: orquestador-providers-mock:local + environment: + MOCK_ADMIN_TOKEN: ${MOCK_ADMIN_TOKEN:?Configura MOCK_ADMIN_TOKEN para la demo} + MOCK_BIND: 0.0.0.0 + MOCK_PORT: "8788" + MOCK_DATA: /mock/data + ports: + - "${MOCK_BIND_ADDRESS:-127.0.0.1}:${MOCK_PORT:-8788}:8788" + volumes: + - mock-data:/mock/data + networks: + - microsipNet + healthcheck: + test: ["CMD", "java", "-cp", "/mock/classes", "MockServer", "--health"] + interval: 10s + timeout: 5s + start_period: 60s + retries: 3 + orquestador: + depends_on: + providers-mock: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-postgres96},demo-mock + FLOW_CATALOG_PATH: /app/mock-config/publication-catalog.json + INTEGRATION_ROUTES_PATH: /app/mock-config/integration-routes.json + volumes: + - ./examples/integrations/publication-catalog.json:/app/mock-config/publication-catalog.json:ro + - ./demo/providers-mock/integration-routes.json:/app/mock-config/integration-routes.json:ro +volumes: + mock-data: diff --git a/demo/providers-mock/.dockerignore b/demo/providers-mock/.dockerignore new file mode 100644 index 0000000..64236ad --- /dev/null +++ b/demo/providers-mock/.dockerignore @@ -0,0 +1,3 @@ +** +!Dockerfile +!MockServer.java diff --git a/demo/providers-mock/Dockerfile b/demo/providers-mock/Dockerfile new file mode 100644 index 0000000..ce08b64 --- /dev/null +++ b/demo/providers-mock/Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:21.0.12.1_1-jdk-jammy AS build +WORKDIR /build +COPY MockServer.java . +RUN javac --release 21 -d classes MockServer.java + +FROM eclipse-temurin:21.0.12.1_1-jre-jammy +WORKDIR /mock +COPY --from=build /build/classes/ /mock/classes/ +RUN mkdir -p /mock/data && chown -R 10001:10001 /mock +USER 10001:10001 +EXPOSE 8788 +ENTRYPOINT ["java", "-cp", "/mock/classes", "MockServer"] diff --git a/demo/providers-mock/MockServer.java b/demo/providers-mock/MockServer.java new file mode 100644 index 0000000..7a91d40 --- /dev/null +++ b/demo/providers-mock/MockServer.java @@ -0,0 +1,126 @@ +import com.sun.net.httpserver.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.*; +import java.util.*; +import java.util.concurrent.*; + +/** Synthetic demo provider. No database, provider calls or request-body logging. */ +public final class MockServer { + static final Set NAMES = Set.of("clientes_consulta", "buro_consulta", "documento_valida", "documents_metadata"); + static final int MAX_BYTES = 262144; + final Path data; + final String token; + final Map calls = new HashMap<>(); + record Reply(int status, int delay, String body) {} + + MockServer(Path data, String token) throws IOException { + this.data = data; this.token = token; + Files.createDirectories(data); + for (String name : NAMES) { + calls.put(name, 0L); + if (!Files.exists(data.resolve(name + ".properties"))) { + String body = switch (name) { + case "clientes_consulta" -> "{\"found\":true,\"customerId\":\"synthetic-customer\"}"; + case "buro_consulta" -> "{\"score\":71,\"consultationId\":\"synthetic-consultation\"}"; + case "documento_valida" -> "{\"status\":\"VALID\",\"reasonCodes\":[]}"; + default -> "{\"error\":\"Configure metadata for this request and document before use\"}"; + }; + save(name, new Reply(name.equals("documents_metadata") ? 404 : 200, 0, body)); + } + } + } + synchronized Reply read(String name) throws IOException { + Properties p = new Properties(); + try (Reader r = Files.newBufferedReader(data.resolve(name + ".properties"))) { p.load(r); } + return checked(p.getProperty("status"), p.getProperty("delayMs"), p.getProperty("body", "")); + } + static Reply checked(String status, String delay, String body) { + int s = Integer.parseInt(status), d = Integer.parseInt(delay); + if (s < 200 || s > 599 || s == 204 || s == 205 || s == 304 || d < 0 || d > 60000 + || body.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) throw new IllegalArgumentException(); + return new Reply(s, d, body); + } + synchronized void save(String name, Reply reply) throws IOException { + Properties p = new Properties(); + p.setProperty("status", Integer.toString(reply.status())); + p.setProperty("delayMs", Integer.toString(reply.delay())); p.setProperty("body", reply.body()); + Path tmp = Files.createTempFile(data, name, ".tmp"); + try { + try (Writer w = Files.newBufferedWriter(tmp)) { p.store(w, "Demo response; updated via admin API"); } + Files.move(tmp, data.resolve(name + ".properties"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(tmp); } + } + synchronized long count(String name, boolean increment) { + if (increment) calls.put(name, calls.get(name) + 1); + return calls.get(name); + } + static void send(HttpExchange x, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + x.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + x.getResponseHeaders().set("Cache-Control", "no-store"); + x.sendResponseHeaders(status, bytes.length); + x.getResponseBody().write(bytes); + } + void handle(HttpExchange x) throws IOException { + try { + String path = x.getRequestURI().getPath(), method = x.getRequestMethod(); + if (path.equals("/health") && method.equals("GET")) { send(x, 200, "{\"status\":\"UP\"}"); return; } + boolean admin = path.startsWith("/__admin/"); + if (admin && !MessageDigest.isEqual(("Bearer " + token).getBytes(StandardCharsets.UTF_8), + Objects.toString(x.getRequestHeaders().getFirst("Authorization"), "").getBytes(StandardCharsets.UTF_8))) { + send(x, 401, "{\"error\":\"Unauthorized\"}"); return; + } + String name = path.substring(admin ? "/__admin/".length() : 1); + if (!NAMES.contains(name)) { send(x, 404, "{\"error\":\"Unknown provider\"}"); return; } + if (admin && method.equals("PUT")) { + byte[] bytes = x.getRequestBody().readNBytes(MAX_BYTES + 1); + if (bytes.length > MAX_BYTES) { send(x, 413, "{\"error\":\"Body too large\"}"); return; } + Reply reply = checked(Objects.toString(x.getRequestHeaders().getFirst("X-Mock-Status"), "200"), + Objects.toString(x.getRequestHeaders().getFirst("X-Mock-Delay-Ms"), "0"), new String(bytes, StandardCharsets.UTF_8)); + save(name, reply); send(x, 200, "{\"saved\":true}"); return; + } + if ((admin && !method.equals("GET")) || (!admin && !method.equals("POST"))) { + send(x, 405, "{\"error\":\"Method not allowed\"}"); return; + } + if (!admin && x.getRequestBody().readNBytes(MAX_BYTES + 1).length > MAX_BYTES) { + send(x, 413, "{\"error\":\"Body too large\"}"); return; + } + Reply reply = read(name); + x.getResponseHeaders().set("X-Mock-Status", Integer.toString(reply.status())); + x.getResponseHeaders().set("X-Mock-Delay-Ms", Integer.toString(reply.delay())); + x.getResponseHeaders().set("X-Mock-Calls", Long.toString(count(name, !admin))); + if (!admin) Thread.sleep(reply.delay()); + send(x, admin ? 200 : reply.status(), reply.body()); + } catch (IllegalArgumentException e) { + send(x, 400, "{\"error\":\"Invalid status or delay; status 200..599 excluding 204/205/304, delay 0..60000\"}"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IOException e) { + // A client timeout/disconnect is expected when simulating a slow provider. + if (x.getResponseCode() == -1) send(x, 500, "{\"error\":\"Response storage unavailable\"}"); + } finally { x.close(); } + } + public static void main(String[] args) throws Exception { + if (args.length == 1 && args[0].equals("--health")) { + var connection = (HttpURLConnection) URI.create("http://localhost:" + System.getenv().getOrDefault("MOCK_PORT", "8788") + "/health").toURL().openConnection(); + connection.setConnectTimeout(2000); connection.setReadTimeout(2000); + try { if (connection.getResponseCode() != 200) System.exit(1); } + finally { connection.disconnect(); } + return; + } + Map env = System.getenv(); + String token = env.getOrDefault("MOCK_ADMIN_TOKEN", ""); + if (token.isBlank()) throw new IllegalArgumentException("MOCK_ADMIN_TOKEN is required"); + String host = env.getOrDefault("MOCK_BIND", "127.0.0.1"); + int port = Integer.parseInt(env.getOrDefault("MOCK_PORT", "8788")); + HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 32); + MockServer handler = new MockServer(Path.of(env.getOrDefault("MOCK_DATA", "data")), token); + ExecutorService executor = Executors.newFixedThreadPool(16); + server.setExecutor(executor); server.createContext("/", handler::handle); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.stop(1); executor.shutdownNow(); })); + server.start(); System.out.println("Demo providers listening on " + host + ":" + server.getAddress().getPort()); + } +} diff --git a/demo/providers-mock/Providers-Mock.postman_collection.json b/demo/providers-mock/Providers-Mock.postman_collection.json new file mode 100644 index 0000000..b683eb3 --- /dev/null +++ b/demo/providers-mock/Providers-Mock.postman_collection.json @@ -0,0 +1,820 @@ +{ + "info": { + "name": "Proveedores mock — configuración manual", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Ejecutar manualmente. Cada PUT reemplaza la respuesta global persistida de un proveedor. GET admin devuelve cuerpo y cabeceras X-Mock-Status, X-Mock-Delay-Ms y X-Mock-Calls. No ejecutar toda la colección: el último PUT sobrescribe los anteriores. Ver README." + }, + "variable": [ + { + "key": "mockUrl", + "value": "http://server02:8788", + "type": "string" + }, + { + "key": "mockAdminToken", + "value": "", + "type": "string" + }, + { + "key": "documentId", + "value": "", + "type": "string" + }, + { + "key": "requestId", + "value": "", + "type": "string" + }, + { + "key": "businessScope", + "value": "demo", + "type": "string" + }, + { + "key": "documentType", + "value": "INE", + "type": "string" + } + ], + "item": [ + { + "name": "Salud", + "request": { + "method": "GET", + "url": "{{mockUrl}}/health", + "header": [], + "auth": { + "type": "noauth" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar: Cliente encontrado", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/clientes_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar: Cliente no encontrado", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/clientes_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"found\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar: Error temporal 503", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/clientes_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "503" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"error\": \"synthetic unavailable\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar: Timeout de consulta", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/clientes_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "7000" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar score 71", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/buro_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"score\": 71,\n \"consultationId\": \"synthetic-consultation\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar score 70", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/buro_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"score\": 70,\n \"consultationId\": \"synthetic-consultation\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar score 61", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/buro_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"score\": 61,\n \"consultationId\": \"synthetic-consultation\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar score 60", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/buro_consulta", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"score\": 60,\n \"consultationId\": \"synthetic-consultation\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar documento válido", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/documento_valida", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"VALID\",\n \"reasonCodes\": []\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar documento inválido", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/documento_valida", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"INVALID\",\n \"reasonCodes\": [\n \"EXPIRED\"\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Configurar metadatos del documento", + "request": { + "method": "PUT", + "url": "{{mockUrl}}/__admin/documents_metadata", + "header": [ + { + "key": "X-Mock-Status", + "value": "200" + }, + { + "key": "X-Mock-Delay-Ms", + "value": "0" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"documentId\": \"{{documentId}}\",\n \"requestId\": \"{{requestId}}\",\n \"businessScope\": \"{{businessScope}}\",\n \"documentType\": \"{{documentType}}\",\n \"storageDocumentId\": \"synthetic-storage\",\n \"mimeType\": \"application/pdf\",\n \"sizeBytes\": 100,\n \"checksum\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n \"scanStatus\": \"CLEAN\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Ver configuración: clientes_consulta", + "request": { + "method": "GET", + "url": "{{mockUrl}}/__admin/clientes_consulta", + "header": [], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Ver configuración: buro_consulta", + "request": { + "method": "GET", + "url": "{{mockUrl}}/__admin/buro_consulta", + "header": [], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Ver configuración: documento_valida", + "request": { + "method": "GET", + "url": "{{mockUrl}}/__admin/documento_valida", + "header": [], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Ver configuración: documents_metadata", + "request": { + "method": "GET", + "url": "{{mockUrl}}/__admin/documents_metadata", + "header": [], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{mockAdminToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200',()=>pm.response.to.have.status(200));" + ] + } + } + ] + }, + { + "name": "Invocar proveedor: clientes_consulta", + "request": { + "method": "POST", + "url": "{{mockUrl}}/clientes_consulta", + "header": [], + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Invocar proveedor: buro_consulta", + "request": { + "method": "POST", + "url": "{{mockUrl}}/buro_consulta", + "header": [], + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Invocar proveedor: documento_valida", + "request": { + "method": "POST", + "url": "{{mockUrl}}/documento_valida", + "header": [], + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Invocar proveedor: documents_metadata", + "request": { + "method": "POST", + "url": "{{mockUrl}}/documents_metadata", + "header": [], + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + } + } + } + ] +} diff --git a/demo/providers-mock/README.md b/demo/providers-mock/README.md new file mode 100644 index 0000000..14c2eba --- /dev/null +++ b/demo/providers-mock/README.md @@ -0,0 +1,139 @@ +# Proveedores de prueba configurables + +Servicio Java 21 independiente, sin dependencias Maven ni base de datos. Responde +por HTTP y permite cambiar desde Postman cuerpo, código HTTP y demora sin +reiniciar. Solo datos sintéticos. No consulta ningún proveedor real. + +## Levantar en server02 + +Desde la raíz del repositorio, agregar a `.env`: + +```dotenv +MOCK_ADMIN_TOKEN=elige-un-token-para-administrar-el-mock +MOCK_BIND_ADDRESS=0.0.0.0 +MOCK_PORT=8788 +``` + +El token es exclusivo del mock, no es un JWT de Keycloak. La publicación del +puerto en 0.0.0.0 permite acceso desde Postman en tu red de demo. + +```bash +docker compose -f compose.yaml -f compose.mock.yaml up -d --build providers-mock +docker compose -f compose.yaml -f compose.mock.yaml ps +``` + +Esperar estado **healthy**. Esta POC usa HTTP: no requiere certificados ni cambios +en la validación SSL de Postman. + +Importar [Providers-Mock.postman_collection.json](Providers-Mock.postman_collection.json). +Configurar `mockAdminToken` con el mismo valor de `.env`. La variable `mockUrl` +ya apunta a `http://server02:8788`. Ejecutar las peticiones individualmente: +cada configuración reemplaza a la anterior, no son escenarios en secuencia. + +## Configurar manualmente + +Ejemplo: cliente inexistente. + +```http +PUT http://server02:8788/__admin/clientes_consulta +Authorization: Bearer TU_TOKEN_DEL_MOCK +X-Mock-Status: 200 +X-Mock-Delay-Ms: 0 +Content-Type: application/json + +{"found": false} +``` + +Después, `POST /clientes_consulta` devolverá ese JSON. Los valores iniciales son: + +| Proveedor | Ruta | Respuesta inicial | +|---|---|---| +| CLIENTES_CONSULTA | /clientes_consulta | 200, found=true, customerId sintético | +| BURO_CONSULTA | /buro_consulta | 200, score=71, consultationId sintético | +| DOCUMENTO_VALIDA | /documento_valida | 200, status=VALID, reasonCodes=[] | +| DOCUMENTS_METADATA | /documents_metadata | 404 hasta configurar los metadatos | + +Para cada ruta, `PUT /__admin/` configura y `GET /__admin/` +consulta. GET administrativo devuelve 200 y el cuerpo configurado; sus cabeceras +`X-Mock-Status`, `X-Mock-Delay-Ms` y `X-Mock-Calls` muestran el código del proveedor, +la demora y el contador de llamadas. Las consultas administrativas no incrementan +el contador ni sufren la demora. Los endpoints de proveedor son POST sin JWT. + +- Para fallo técnico: X-Mock-Status=503 y un cuerpo de error sintético. +- Para timeout: X-Mock-Delay-Ms=7000; las rutas de demo tienen timeout de 5000 ms. +- Para restaurar: volver a enviar PUT con status=200 y delay=0. +- Para scoring: editar el número score (71, 70, 61 o 60). +- Para documento inválido: `{"status":"INVALID","reasonCodes":["EXPIRED"]}`. +- También acepta texto/JSON incorrecto deliberadamente para comprobar el rechazo + por contrato del orquestador. No aplica validación de negocio al cuerpo ficticio. + +Los PUT devuelven `{"saved":true}` con HTTP 200, aunque el proveedor se haya +configurado con 503. Status admite 200..599 excepto 204/205/304; demora 0..60000 ms; +cuerpos de hasta 256 KiB. Omisión de cabeceras restablece status=200 y delay=0. + +## Conectar el orquestador + +El archivo base `compose.yaml` conserva un solo servicio. La combinación con +`compose.mock.yaml` añade el mock a `microsipNet` y selecciona catálogo/rutas +sin sobrescribir `config/`. Activa el perfil `demo-mock` junto al perfil configurado +(por defecto postgres96). Ese perfil permite HTTP únicamente hacia el nombre +`providers-mock` y puerto 8788. Sin ese perfil se mantiene el requisito HTTPS +normal; local/test conserva su excepción previa para loopback. + +Antes de habilitar la demo, configurar en `.env` REQUEST_API_ENABLED=true, +WORKER_ENABLED=true y, para administrar flujos, FLOW_ADMIN_ENABLED=true y +ADMIN_BUSINESS_SCOPES. Se requieren el usuario DB de runtime restringido y las +credenciales separadas de migración descritos en [SECURITY.md](../../specs/SECURITY.md). +Los JWT siguen procediendo de tu Keycloak. Este servicio no genera tokens JWT ni +crea tipos, flujos, solicitudes o documentos automáticamente. + +Aplicar en el entorno de demo: + +```bash +docker compose -f compose.yaml -f compose.mock.yaml up -d --build +``` + +El orquestador espera a que el mock esté healthy. No se cambian JAVA_TOOL_OPTIONS +ni truststores. Si ya habías levantado la versión HTTPS, reconstruir y recrear +ambos servicios con el comando anterior y reimportar la colección Postman. +Los antiguos volúmenes de certificados quedan sin uso; mock-data conserva las +respuestas. No se borran volúmenes automáticamente. + +Para COMPRA: configurar cliente encontrado, crear/activar el flujo de compra, +crear solicitud, enviar ASIGNAR_CLIENTE, consultar ejecución hasta SUCCEEDED, +refrescar revisión y enviar CONFIRMAR_COMPRA. Para probar cliente no encontrado, +usar otra solicitud o la que sigue CREADA, con una clave de evento nueva. +Repetir la misma clave reproduce la aceptación anterior, no fuerza otra consulta. + +## Metadatos documentales y límites + +La colección incluye un PUT de metadatos: completar documentId, requestId, +businessScope y documentType con los de la solicitud. Los campos de propiedad, +MIME, tamaño, checksum y scanStatus=CLEAN son respuestas sintéticas, no un análisis +real de archivos. No hay carga ni almacenamiento de documentos personales. + +La respuesta es global por endpoint: no se selecciona por cliente, documento, +perfil ni clave idempotente. Todos los perfiles DOCUMENTO_VALIDA comparten la +respuesta. Para probar varios documentos, configurar el correspondiente antes +de cada evento y esperar a que termine antes de cambiarlo. Una llamada en curso +conserva la respuesta que leyó al comenzar; las siguientes usan la configuración +nueva. Esto incluye reintentos automáticos del worker. + +Las respuestas sobreviven a reinicios en el volumen mock-data; los contadores +se reinician. El token administrativo se conserva, pero esta POC usa HTTP sin +cifrado dentro de la red de pruebas. No se registran cuerpos ni tokens. No se +simulan efectos externos de escritura ni deduplicación de un proveedor: las +integraciones de esta demo son READ_ONLY. + +## Validación local sin Docker + +Con Python 3 y JAVA_HOME apuntando a JDK 21: + +```bash +python demo/providers-mock/test_mock.py +``` + +Compila el servidor y prueba HTTP real, +autorización administrativa, cliente encontrado/no encontrado, status/demora, +persistencia tras reinicio, scoring, documentos y respuestas malformadas. No usa +PostgreSQL ni proveedores externos. No equivale a ejecutar el Compose ni cerrar T16. diff --git a/demo/providers-mock/integration-routes.json b/demo/providers-mock/integration-routes.json new file mode 100644 index 0000000..a0e77f6 --- /dev/null +++ b/demo/providers-mock/integration-routes.json @@ -0,0 +1,82 @@ +{ + "routes": [ + { + "integrationKey": "CLIENTES_CONSULTA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "http://providers-mock:8788/clientes_consulta", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "LICENCIA_CONDUCIR", + "profileVersion": "1", + "url": "http://providers-mock:8788/documento_valida", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "BURO_CONSULTA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "http://providers-mock:8788/buro_consulta", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTS_METADATA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "http://providers-mock:8788/documents_metadata", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "INE", + "profileVersion": "1", + "url": "http://providers-mock:8788/documento_valida", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "ESTADO_CUENTA", + "profileVersion": "1", + "url": "http://providers-mock:8788/documento_valida", + "tokenEnvironment": "", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + } + ] +} diff --git a/demo/providers-mock/test_mock.py b/demo/providers-mock/test_mock.py new file mode 100644 index 0000000..af56102 --- /dev/null +++ b/demo/providers-mock/test_mock.py @@ -0,0 +1,112 @@ +"""Real HTTP checks of the standalone Java demo service; no external providers.""" +import json +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import time +import unittest +import urllib.request +import urllib.error + +HERE = Path(__file__).resolve().parent + + +class MockTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory() + cls.work = Path(cls.temp.name) + cls.java = str(Path(os.environ['JAVA_HOME']) / 'bin' / 'java') + javac = str(Path(os.environ['JAVA_HOME']) / 'bin' / 'javac') + subprocess.run([javac, '--release', '21', '-d', str(cls.work), str(HERE / 'MockServer.java')], check=True) + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + cls.port = s.getsockname()[1] + cls.base = f'http://localhost:{cls.port}' + cls.env = dict(os.environ, MOCK_ADMIN_TOKEN='synthetic-test-token', MOCK_DATA=str(cls.work / 'data'), + MOCK_PORT=str(cls.port), MOCK_BIND='127.0.0.1') + cls.start() + + @classmethod + def start(cls): + cls.proc = subprocess.Popen([cls.java, '-cp', str(cls.work), 'MockServer'], env=cls.env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(100): + try: + with urllib.request.urlopen(cls.base + '/health', timeout=1) as r: + if r.status == 200: + return + except (OSError, urllib.error.URLError): + time.sleep(.1) + cls.proc.terminate() + cls.proc.wait(timeout=10) + raise AssertionError('Mock failed to start') + + @classmethod + def tearDownClass(cls): + cls.proc.terminate() + cls.proc.wait(timeout=10) + cls.temp.cleanup() + + def call(self, path, method='POST', body=b'{}', headers=None): + req = urllib.request.Request(self.base + path, data=body if method != 'GET' else None, + headers=headers or {}, method=method) + try: + r = urllib.request.urlopen(req, timeout=3) + except urllib.error.HTTPError as e: + r = e + with r: + return r.status, r.read(), r.headers + + def set_response(self, name, body, status=200, delay=0): + return self.call('/__admin/' + name, 'PUT', body, {'Authorization': 'Bearer synthetic-test-token', + 'X-Mock-Status': str(status), 'X-Mock-Delay-Ms': str(delay)}) + + def test_auth_limits_and_unknown_paths(self): + self.assertEqual(401, self.call('/__admin/clientes_consulta', 'PUT')[0]) + self.assertEqual(404, self.call('/missing')[0]) + self.assertEqual(405, self.call('/clientes_consulta', 'GET')[0]) + self.assertEqual(400, self.set_response('clientes_consulta', b'{}', delay=-1)[0]) + self.assertEqual(400, self.set_response('clientes_consulta', b'{}', status=204)[0]) + self.assertEqual(413, self.set_response('clientes_consulta', b'x' * 262145)[0]) + + def test_java_healthcheck_and_custom_metadata(self): + subprocess.run([self.java, '-cp', str(self.work), + 'MockServer', '--health'], env=self.env, check=True, timeout=15, capture_output=True) + body = b'{"documentId":"synthetic-document","requestId":"synthetic-request"}' + self.assertEqual(200, self.set_response('documents_metadata', body)[0]) + self.assertEqual((200, body), self.call('/documents_metadata')[:2]) + self.set_response('documents_metadata', b'{"error":"not configured"}', status=404) + + def test_configurable_results_errors_delay_and_restart(self): + for body in (b'{"found":true,"customerId":"synthetic"}', b'{"found":false}'): + self.assertEqual(200, self.set_response('clientes_consulta', body)[0]) + self.assertEqual((200, body), self.call('/clientes_consulta')[:2]) + self.set_response('clientes_consulta', b'{"error":"unavailable"}', status=503, delay=200) + before = time.monotonic() + self.assertEqual(503, self.call('/clientes_consulta')[0]) + self.assertGreaterEqual(time.monotonic() - before, .18) + cls = type(self) + cls.proc.terminate(); cls.proc.wait(timeout=10); cls.start() + status, body, headers = self.call('/__admin/clientes_consulta', 'GET', headers={'Authorization':'Bearer synthetic-test-token'}) + self.assertEqual(200, status) + self.assertEqual('503', headers['X-Mock-Status']) + self.assertEqual('200', headers['X-Mock-Delay-Ms']) + self.assertEqual('0', headers['X-Mock-Calls']) + + def test_scoring_documents_and_malformed_output(self): + for score in (71, 70, 61, 60): + body = json.dumps({'score': score, 'consultationId': 'synthetic'}).encode() + self.set_response('buro_consulta', body) + self.assertEqual((200, body), self.call('/buro_consulta')[:2]) + self.set_response('documento_valida', b'{"status":"INVALID","reasonCodes":["EXPIRED"]}') + self.assertEqual('INVALID', json.loads(self.call('/documento_valida')[1])['status']) + self.assertEqual(404, self.call('/documents_metadata')[0]) + self.set_response('buro_consulta', b'not-json') + self.assertEqual(b'not-json', self.call('/buro_consulta')[1]) + + +if __name__ == '__main__': + unittest.main() diff --git a/examples/postman/Orquestador-NMP.postman_collection.json b/examples/postman/Orquestador-NMP.postman_collection.json new file mode 100644 index 0000000..efbe7c0 --- /dev/null +++ b/examples/postman/Orquestador-NMP.postman_collection.json @@ -0,0 +1,1173 @@ +{ + "info": { + "name": "Orquestador NMP — server02", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Ejecutar solicitudes manualmente por carpetas. No ejecutar toda la colección: publica y activa flujos, crea solicitudes y acepta eventos. Usar entorno de demo y datos sintéticos. Tokens vacíos: configurar en variables de colección. Los tokens específicos vacíos usan accessToken. Ver README.md adjunto. Las pruebas funcionales requieren flags, rol DB restringido, catálogo y proveedores configurados." + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://server02:8787", + "type": "string" + }, + { + "key": "accessToken", + "value": "", + "type": "string" + }, + { + "key": "editorToken", + "value": "", + "type": "string" + }, + { + "key": "publisherToken", + "value": "", + "type": "string" + }, + { + "key": "adminToken", + "value": "", + "type": "string" + }, + { + "key": "operatorToken", + "value": "", + "type": "string" + }, + { + "key": "flowId", + "value": "", + "type": "string" + }, + { + "key": "flowRevision", + "value": "0", + "type": "string" + }, + { + "key": "typeKey", + "value": "CREDITO", + "type": "string" + }, + { + "key": "typeRevision", + "value": "0", + "type": "string" + }, + { + "key": "requestId", + "value": "", + "type": "string" + }, + { + "key": "requestRevision", + "value": "0", + "type": "string" + }, + { + "key": "executionId", + "value": "", + "type": "string" + }, + { + "key": "createKey", + "value": "", + "type": "string" + }, + { + "key": "eventKey", + "value": "", + "type": "string" + }, + { + "key": "eventRevision", + "value": "", + "type": "string" + }, + { + "key": "eventBody", + "value": "", + "type": "string" + }, + { + "key": "documentId", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "01 — Salud y seguridad", + "item": [ + { + "name": "Salud", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/actuator/health", + "description": "", + "auth": { + "type": "noauth" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('UP',()=>pm.expect(pm.response.json().status).to.eql('UP'))", + "}" + ] + } + } + ] + }, + { + "name": "Readiness PostgreSQL", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/actuator/health/readiness", + "description": "", + "auth": { + "type": "noauth" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('UP',()=>pm.expect(pm.response.json().status).to.eql('UP'))", + "}" + ] + } + } + ] + }, + { + "name": "Liveness", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/actuator/health/liveness", + "description": "", + "auth": { + "type": "noauth" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "", + "}" + ] + } + } + ] + }, + { + "name": "Rechazo sin token", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/v1/requests", + "description": "", + "auth": { + "type": "noauth" + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 401', () => pm.response.to.have.status(401));", + "if (pm.response.code === 401) {", + "", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "02 — Preparar borrador CREDITO v1", + "item": [ + { + "name": "Crear borrador (una vez)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows", + "description": "Requiere FLOW_ADMIN_ENABLED, FLOW_EDITOR y business_scope permitido. Guarda flowId y flowRevision. Si ya existe, usar el ID conocido.", + "body": { + "mode": "raw", + "raw": "{\n \"schemaVersion\": 1,\n \"flowKey\": \"CREDITO\",\n \"version\": 1,\n \"initialState\": \"CREADA\",\n \"initialDataSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"client\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"consentimientoId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"client\",\n \"consentimientoId\"\n ],\n \"additionalProperties\": false\n },\n \"dataSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"client\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"consentimientoId\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"cliente\": {\n \"type\": \"object\",\n \"properties\": {\n \"found\": {\n \"type\": \"boolean\"\n },\n \"customerId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"found\"\n ],\n \"additionalProperties\": false,\n \"allOf\": [\n {\n \"if\": {\n \"properties\": {\n \"found\": {\n \"const\": true\n }\n }\n },\n \"then\": {\n \"required\": [\n \"customerId\"\n ]\n }\n }\n ]\n },\n \"documentos\": {\n \"type\": \"object\",\n \"properties\": {\n \"ine\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n },\n \"cuenta\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n }\n },\n \"required\": [],\n \"additionalProperties\": false\n },\n \"validaciones\": {\n \"type\": \"object\",\n \"properties\": {\n \"ine\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"VALID\",\n \"INVALID\"\n ]\n },\n \"reasonCodes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n }\n },\n \"required\": [\n \"status\",\n \"reasonCodes\"\n ],\n \"additionalProperties\": false\n },\n \"cuenta\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"VALID\",\n \"INVALID\"\n ]\n },\n \"reasonCodes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n }\n },\n \"required\": [\n \"status\",\n \"reasonCodes\"\n ],\n \"additionalProperties\": false\n },\n \"buro\": {\n \"type\": \"object\",\n \"properties\": {\n \"score\": {\n \"type\": \"number\"\n },\n \"consultationId\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"required\": [\n \"score\",\n \"consultationId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"client\",\n \"consentimientoId\"\n ],\n \"additionalProperties\": false\n },\n \"states\": [\n {\n \"key\": \"CREADA\",\n \"terminal\": false\n },\n {\n \"key\": \"INE_PENDIENTE\",\n \"terminal\": false\n },\n {\n \"key\": \"ESTADO_CUENTA_PENDIENTE\",\n \"terminal\": false\n },\n {\n \"key\": \"BURO_PENDIENTE\",\n \"terminal\": false\n },\n {\n \"key\": \"PASO_N\",\n \"terminal\": true\n },\n {\n \"key\": \"PASO_M\",\n \"terminal\": true\n },\n {\n \"key\": \"REVISION_MANUAL\",\n \"terminal\": true\n }\n ],\n \"events\": [\n {\n \"key\": \"ASIGNAR_CLIENTE\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"required\": [],\n \"additionalProperties\": false\n }\n },\n {\n \"key\": \"REGISTRAR_INE\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"documentId\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n }\n },\n \"required\": [\n \"documentId\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"key\": \"REGISTRAR_ESTADO_CUENTA\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"documentId\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n }\n },\n \"required\": [\n \"documentId\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"key\": \"VALIDAR_BURO\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"required\": [],\n \"additionalProperties\": false\n }\n }\n ],\n \"transitions\": [\n {\n \"id\": \"asignar-cliente\",\n \"from\": \"CREADA\",\n \"event\": \"ASIGNAR_CLIENTE\",\n \"allowedRoles\": [\n \"ANALISTA\"\n ],\n \"actions\": [\n {\n \"id\": \"cliente\",\n \"type\": \"INVOKE_INTEGRATION\",\n \"integrationKey\": \"CLIENTES_CONSULTA\",\n \"contractVersion\": \"1\",\n \"inputs\": {\n \"client\": {\n \"path\": \"/request/data/client\"\n }\n },\n \"resultKey\": \"cliente\"\n }\n ],\n \"decision\": \"CLIENTE_EXISTE\",\n \"destinations\": {\n \"CLIENTE_ENCONTRADO\": \"INE_PENDIENTE\",\n \"CLIENTE_NO_ENCONTRADO\": \"CREADA\"\n },\n \"updates\": [\n {\n \"target\": \"/cliente\",\n \"value\": {\n \"path\": \"/results/cliente\"\n }\n }\n ]\n },\n {\n \"id\": \"documento-ine\",\n \"from\": \"INE_PENDIENTE\",\n \"event\": \"REGISTRAR_INE\",\n \"allowedRoles\": [\n \"ANALISTA\"\n ],\n \"actions\": [\n {\n \"id\": \"registro\",\n \"type\": \"REGISTER_DOCUMENT\",\n \"documentType\": \"INE\",\n \"inputs\": {\n \"documentId\": {\n \"path\": \"/event/data/documentId\"\n }\n },\n \"resultKey\": \"registro\"\n },\n {\n \"id\": \"validacion\",\n \"type\": \"INVOKE_INTEGRATION\",\n \"integrationKey\": \"DOCUMENTO_VALIDA\",\n \"contractVersion\": \"1\",\n \"profile\": \"INE\",\n \"profileVersion\": \"1\",\n \"inputs\": {\n \"documentId\": {\n \"path\": \"/results/registro/documentId\"\n }\n },\n \"resultKey\": \"validacion\"\n }\n ],\n \"decision\": \"VALIDAR_INE\",\n \"destinations\": {\n \"DOCUMENTO_VALIDO\": \"ESTADO_CUENTA_PENDIENTE\",\n \"DOCUMENTO_INVALIDO\": \"INE_PENDIENTE\"\n },\n \"updates\": [\n {\n \"target\": \"/documentos/ine\",\n \"value\": {\n \"path\": \"/results/registro/documentId\"\n }\n },\n {\n \"target\": \"/validaciones/ine\",\n \"value\": {\n \"path\": \"/results/validacion\"\n }\n }\n ]\n },\n {\n \"id\": \"documento-cuenta\",\n \"from\": \"ESTADO_CUENTA_PENDIENTE\",\n \"event\": \"REGISTRAR_ESTADO_CUENTA\",\n \"allowedRoles\": [\n \"ANALISTA\"\n ],\n \"actions\": [\n {\n \"id\": \"registro\",\n \"type\": \"REGISTER_DOCUMENT\",\n \"documentType\": \"ESTADO_CUENTA\",\n \"inputs\": {\n \"documentId\": {\n \"path\": \"/event/data/documentId\"\n }\n },\n \"resultKey\": \"registro\"\n },\n {\n \"id\": \"validacion\",\n \"type\": \"INVOKE_INTEGRATION\",\n \"integrationKey\": \"DOCUMENTO_VALIDA\",\n \"contractVersion\": \"1\",\n \"profile\": \"ESTADO_CUENTA\",\n \"profileVersion\": \"1\",\n \"inputs\": {\n \"documentId\": {\n \"path\": \"/results/registro/documentId\"\n }\n },\n \"resultKey\": \"validacion\"\n }\n ],\n \"decision\": \"VALIDAR_ESTADO_CUENTA\",\n \"destinations\": {\n \"DOCUMENTO_VALIDO\": \"BURO_PENDIENTE\",\n \"DOCUMENTO_INVALIDO\": \"ESTADO_CUENTA_PENDIENTE\"\n },\n \"updates\": [\n {\n \"target\": \"/documentos/cuenta\",\n \"value\": {\n \"path\": \"/results/registro/documentId\"\n }\n },\n {\n \"target\": \"/validaciones/cuenta\",\n \"value\": {\n \"path\": \"/results/validacion\"\n }\n }\n ]\n },\n {\n \"id\": \"consultar-buro\",\n \"from\": \"BURO_PENDIENTE\",\n \"event\": \"VALIDAR_BURO\",\n \"allowedRoles\": [\n \"ANALISTA\"\n ],\n \"actions\": [\n {\n \"id\": \"buro\",\n \"type\": \"INVOKE_INTEGRATION\",\n \"integrationKey\": \"BURO_CONSULTA\",\n \"contractVersion\": \"1\",\n \"inputs\": {\n \"client\": {\n \"path\": \"/request/data/cliente/customerId\"\n },\n \"consentimientoId\": {\n \"path\": \"/request/data/consentimientoId\"\n }\n },\n \"resultKey\": \"buro\"\n }\n ],\n \"decision\": \"RUTA_SCORING\",\n \"destinations\": {\n \"N\": \"PASO_N\",\n \"M\": \"PASO_M\",\n \"MANUAL\": \"REVISION_MANUAL\"\n },\n \"updates\": [\n {\n \"target\": \"/validaciones/buro\",\n \"value\": {\n \"path\": \"/results/buro\"\n }\n }\n ]\n }\n ],\n \"decisions\": [\n {\n \"id\": \"CLIENTE_EXISTE\",\n \"hitPolicy\": \"UNIQUE\",\n \"rules\": [\n {\n \"id\": \"encontrado\",\n \"when\": {\n \"op\": \"EQ\",\n \"left\": {\n \"path\": \"/results/cliente/found\"\n },\n \"right\": {\n \"literal\": true\n }\n },\n \"output\": \"CLIENTE_ENCONTRADO\"\n }\n ],\n \"defaultOutput\": \"CLIENTE_NO_ENCONTRADO\"\n },\n {\n \"id\": \"VALIDAR_INE\",\n \"hitPolicy\": \"UNIQUE\",\n \"rules\": [\n {\n \"id\": \"valido\",\n \"when\": {\n \"op\": \"EQ\",\n \"left\": {\n \"path\": \"/results/validacion/status\"\n },\n \"right\": {\n \"literal\": \"VALID\"\n }\n },\n \"output\": \"DOCUMENTO_VALIDO\"\n }\n ],\n \"defaultOutput\": \"DOCUMENTO_INVALIDO\"\n },\n {\n \"id\": \"VALIDAR_ESTADO_CUENTA\",\n \"hitPolicy\": \"UNIQUE\",\n \"rules\": [\n {\n \"id\": \"valido\",\n \"when\": {\n \"op\": \"EQ\",\n \"left\": {\n \"path\": \"/results/validacion/status\"\n },\n \"right\": {\n \"literal\": \"VALID\"\n }\n },\n \"output\": \"DOCUMENTO_VALIDO\"\n }\n ],\n \"defaultOutput\": \"DOCUMENTO_INVALIDO\"\n },\n {\n \"id\": \"RUTA_SCORING\",\n \"hitPolicy\": \"UNIQUE\",\n \"rules\": [\n {\n \"id\": \"alto\",\n \"when\": {\n \"op\": \"GT\",\n \"left\": {\n \"path\": \"/results/buro/score\"\n },\n \"right\": {\n \"literal\": 70\n }\n },\n \"output\": \"N\"\n },\n {\n \"id\": \"medio\",\n \"when\": {\n \"op\": \"AND\",\n \"args\": [\n {\n \"op\": \"GT\",\n \"left\": {\n \"path\": \"/results/buro/score\"\n },\n \"right\": {\n \"literal\": 60\n }\n },\n {\n \"op\": \"LTE\",\n \"left\": {\n \"path\": \"/results/buro/score\"\n },\n \"right\": {\n \"literal\": 70\n }\n }\n ]\n },\n \"output\": \"M\"\n }\n ],\n \"defaultOutput\": \"MANUAL\"\n }\n ],\n \"tasks\": [\n {\n \"state\": \"CREADA\",\n \"event\": \"ASIGNAR_CLIENTE\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"required\": [],\n \"additionalProperties\": false\n },\n \"ui\": {\n \"component\": \"FORM\",\n \"title\": \"Consultar cliente\",\n \"fieldOrder\": []\n }\n },\n {\n \"state\": \"INE_PENDIENTE\",\n \"event\": \"REGISTRAR_INE\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"documentId\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n }\n },\n \"required\": [\n \"documentId\"\n ],\n \"additionalProperties\": false\n },\n \"ui\": {\n \"component\": \"DOCUMENT_UPLOAD\",\n \"title\": \"Identificación INE\",\n \"documentType\": \"INE\",\n \"acceptedMimeTypes\": [\n \"application/pdf\",\n \"image/jpeg\",\n \"image/png\"\n ],\n \"maxBytes\": 5242880\n }\n },\n {\n \"state\": \"ESTADO_CUENTA_PENDIENTE\",\n \"event\": \"REGISTRAR_ESTADO_CUENTA\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"documentId\": {\n \"type\": \"string\",\n \"format\": \"uuid\"\n }\n },\n \"required\": [\n \"documentId\"\n ],\n \"additionalProperties\": false\n },\n \"ui\": {\n \"component\": \"DOCUMENT_UPLOAD\",\n \"title\": \"Estado de cuenta\",\n \"documentType\": \"ESTADO_CUENTA\",\n \"acceptedMimeTypes\": [\n \"application/pdf\"\n ],\n \"maxBytes\": 5242880\n }\n },\n {\n \"state\": \"BURO_PENDIENTE\",\n \"event\": \"VALIDAR_BURO\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {},\n \"required\": [],\n \"additionalProperties\": false\n },\n \"ui\": {\n \"component\": \"FORM\",\n \"title\": \"Consultar buró con consentimiento registrado\",\n \"fieldOrder\": []\n }\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 201', () => pm.response.to.have.status(201));", + "if (pm.response.code === 201) {", + "const b=pm.response.json(); pm.collectionVariables.set('flowId',b.id); pm.collectionVariables.set('flowRevision',String(b.revision));", + "}" + ] + } + } + ] + }, + { + "name": "Validar borrador", + "request": { + "method": "POST", + "header": [], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/validate", + "description": "Revisar el informe completo antes de publicar; HTTP 200 no implica validación sin errores.", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "03 — Simular scoring sin proveedores", + "item": [ + { + "name": "Score 71 — PASO_N", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/simulate", + "description": "Simulación pura: no invoca proveedores ni crea solicitudes. Necesita catálogo de ejemplos y flowId del crédito v1.", + "body": { + "mode": "raw", + "raw": "{\n \"state\": \"BURO_PENDIENTE\",\n \"requestData\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\",\n \"cliente\": {\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n }\n },\n \"event\": {\n \"event\": \"VALIDAR_BURO\",\n \"data\": {}\n },\n \"actor\": {\n \"subject\": \"synthetic-analyst\",\n \"businessScope\": \"demo\",\n \"roles\": [\n \"ANALISTA\"\n ]\n },\n \"results\": {\n \"buro\": {\n \"score\": 71,\n \"consultationId\": \"synthetic-consultation\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('Estado esperado',()=>pm.expect(pm.response.json().toState).to.eql('PASO_N'));", + "}" + ] + } + } + ] + }, + { + "name": "Score 70 — PASO_M", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/simulate", + "description": "Simulación pura: no invoca proveedores ni crea solicitudes. Necesita catálogo de ejemplos y flowId del crédito v1.", + "body": { + "mode": "raw", + "raw": "{\n \"state\": \"BURO_PENDIENTE\",\n \"requestData\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\",\n \"cliente\": {\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n }\n },\n \"event\": {\n \"event\": \"VALIDAR_BURO\",\n \"data\": {}\n },\n \"actor\": {\n \"subject\": \"synthetic-analyst\",\n \"businessScope\": \"demo\",\n \"roles\": [\n \"ANALISTA\"\n ]\n },\n \"results\": {\n \"buro\": {\n \"score\": 70,\n \"consultationId\": \"synthetic-consultation\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('Estado esperado',()=>pm.expect(pm.response.json().toState).to.eql('PASO_M'));", + "}" + ] + } + } + ] + }, + { + "name": "Score 61 — PASO_M", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/simulate", + "description": "Simulación pura: no invoca proveedores ni crea solicitudes. Necesita catálogo de ejemplos y flowId del crédito v1.", + "body": { + "mode": "raw", + "raw": "{\n \"state\": \"BURO_PENDIENTE\",\n \"requestData\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\",\n \"cliente\": {\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n }\n },\n \"event\": {\n \"event\": \"VALIDAR_BURO\",\n \"data\": {}\n },\n \"actor\": {\n \"subject\": \"synthetic-analyst\",\n \"businessScope\": \"demo\",\n \"roles\": [\n \"ANALISTA\"\n ]\n },\n \"results\": {\n \"buro\": {\n \"score\": 61,\n \"consultationId\": \"synthetic-consultation\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('Estado esperado',()=>pm.expect(pm.response.json().toState).to.eql('PASO_M'));", + "}" + ] + } + } + ] + }, + { + "name": "Score 60 — REVISION_MANUAL", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/simulate", + "description": "Simulación pura: no invoca proveedores ni crea solicitudes. Necesita catálogo de ejemplos y flowId del crédito v1.", + "body": { + "mode": "raw", + "raw": "{\n \"state\": \"BURO_PENDIENTE\",\n \"requestData\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\",\n \"cliente\": {\n \"found\": true,\n \"customerId\": \"synthetic-customer\"\n }\n },\n \"event\": {\n \"event\": \"VALIDAR_BURO\",\n \"data\": {}\n },\n \"actor\": {\n \"subject\": \"synthetic-analyst\",\n \"businessScope\": \"demo\",\n \"roles\": [\n \"ANALISTA\"\n ]\n },\n \"results\": {\n \"buro\": {\n \"score\": 60,\n \"consultationId\": \"synthetic-consultation\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('editorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.test('Estado esperado',()=>pm.expect(pm.response.json().toState).to.eql('REVISION_MANUAL'));", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "04 — Publicar y activar (modifica catálogo)", + "item": [ + { + "name": "Publicar flujo validado", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{flowRevision}}\"" + } + ], + "url": "{{baseUrl}}/api/v1/admin/flows/{{flowId}}/publish", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('publisherToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "const b=pm.response.json(); pm.collectionVariables.set('flowId',b.id); pm.collectionVariables.set('flowRevision',String(b.revision));", + "}" + ] + } + } + ] + }, + { + "name": "Activar para CREDITO", + "request": { + "method": "PUT", + "header": [ + { + "key": "If-Match", + "value": "\"{{typeRevision}}\"" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/admin/request-types/{{typeKey}}/active-flow", + "description": "El tipo debe existir en PostgreSQL. typeRevision=0 solo para un tipo nuevo; introducir la revisión vigente. No hay endpoint GET de tipos.", + "body": { + "mode": "raw", + "raw": "{\n \"flowId\": \"{{flowId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('adminToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.collectionVariables.set('typeRevision',String(pm.response.json().revision));", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "05 — Solicitudes", + "item": [ + { + "name": "Crear NUEVA solicitud", + "request": { + "method": "POST", + "header": [ + { + "key": "Idempotency-Key", + "value": "{{createKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests", + "description": "", + "body": { + "mode": "raw", + "raw": "{\n \"typeKey\": \"{{typeKey}}\",\n \"data\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('createKey',pm.variables.replaceIn('{{$guid}}'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 201', () => pm.response.to.have.status(201));", + "if (pm.response.code === 201) {", + "const b=pm.response.json(); pm.collectionVariables.set('requestId',b.id); pm.collectionVariables.set('requestRevision',String(b.revision));", + "}" + ] + } + } + ] + }, + { + "name": "Repetir creación con misma clave y cuerpo", + "request": { + "method": "POST", + "header": [ + { + "key": "Idempotency-Key", + "value": "{{createKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests", + "description": "Ejecutar después de crear. No cambiar cuerpo, typeKey ni usuario.", + "body": { + "mode": "raw", + "raw": "{\n \"typeKey\": \"{{typeKey}}\",\n \"data\": {\n \"client\": \"synthetic-client\",\n \"consentimientoId\": \"synthetic-consent\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 201', () => pm.response.to.have.status(201));", + "if (pm.response.code === 201) {", + "pm.test('Misma solicitud',()=>pm.expect(pm.response.json().id).to.eql(pm.collectionVariables.get('requestId')));", + "}" + ] + } + } + ] + }, + { + "name": "Consultar solicitud y actualizar revisión", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}", + "description": "" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "const b=pm.response.json(); pm.collectionVariables.set('requestId',b.id); pm.collectionVariables.set('requestRevision',String(b.revision));", + "}" + ] + } + } + ] + }, + { + "name": "Consultar eventos disponibles", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/available-events", + "description": "" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "pm.collectionVariables.set('requestRevision',String(pm.response.json().revision));", + "}" + ] + } + } + ] + }, + { + "name": "Historial", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/history?limit=50", + "description": "" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "06 — Eventos reales (solo con proveedores de demo)", + "item": [ + { + "name": "ASIGNAR_CLIENTE", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{eventRevision}}\"" + }, + { + "key": "Idempotency-Key", + "value": "{{eventKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/events", + "description": "Solo enviar si aparece en available-events. Después consultar ejecución y refrescar solicitud/revisión. Requiere worker y proveedores mock configurados; documentId debe pertenecer a esta solicitud. No usar buró real para demo.", + "body": { + "mode": "raw", + "raw": "{\n \"event\": \"ASIGNAR_CLIENTE\",\n \"data\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('eventKey',pm.variables.replaceIn('{{$guid}}'));", + "pm.collectionVariables.set('eventRevision',pm.variables.get('requestRevision'));", + "pm.collectionVariables.set('eventBody',pm.variables.replaceIn(pm.request.body.raw));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 202', () => pm.response.to.have.status(202));", + "if (pm.response.code === 202) {", + "pm.collectionVariables.set('executionId',pm.response.json().executionId);", + "}" + ] + } + } + ] + }, + { + "name": "REGISTRAR_INE", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{eventRevision}}\"" + }, + { + "key": "Idempotency-Key", + "value": "{{eventKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/events", + "description": "Solo enviar si aparece en available-events. Después consultar ejecución y refrescar solicitud/revisión. Requiere worker y proveedores mock configurados; documentId debe pertenecer a esta solicitud. No usar buró real para demo.", + "body": { + "mode": "raw", + "raw": "{\n \"event\": \"REGISTRAR_INE\",\n \"data\": {\n \"documentId\": \"{{documentId}}\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('eventKey',pm.variables.replaceIn('{{$guid}}'));", + "pm.collectionVariables.set('eventRevision',pm.variables.get('requestRevision'));", + "pm.collectionVariables.set('eventBody',pm.variables.replaceIn(pm.request.body.raw));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 202', () => pm.response.to.have.status(202));", + "if (pm.response.code === 202) {", + "pm.collectionVariables.set('executionId',pm.response.json().executionId);", + "}" + ] + } + } + ] + }, + { + "name": "REGISTRAR_ESTADO_CUENTA", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{eventRevision}}\"" + }, + { + "key": "Idempotency-Key", + "value": "{{eventKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/events", + "description": "Solo enviar si aparece en available-events. Después consultar ejecución y refrescar solicitud/revisión. Requiere worker y proveedores mock configurados; documentId debe pertenecer a esta solicitud. No usar buró real para demo.", + "body": { + "mode": "raw", + "raw": "{\n \"event\": \"REGISTRAR_ESTADO_CUENTA\",\n \"data\": {\n \"documentId\": \"{{documentId}}\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('eventKey',pm.variables.replaceIn('{{$guid}}'));", + "pm.collectionVariables.set('eventRevision',pm.variables.get('requestRevision'));", + "pm.collectionVariables.set('eventBody',pm.variables.replaceIn(pm.request.body.raw));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 202', () => pm.response.to.have.status(202));", + "if (pm.response.code === 202) {", + "pm.collectionVariables.set('executionId',pm.response.json().executionId);", + "}" + ] + } + } + ] + }, + { + "name": "VALIDAR_BURO", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{eventRevision}}\"" + }, + { + "key": "Idempotency-Key", + "value": "{{eventKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/events", + "description": "Solo enviar si aparece en available-events. Después consultar ejecución y refrescar solicitud/revisión. Requiere worker y proveedores mock configurados; documentId debe pertenecer a esta solicitud. No usar buró real para demo.", + "body": { + "mode": "raw", + "raw": "{\n \"event\": \"VALIDAR_BURO\",\n \"data\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.set('eventKey',pm.variables.replaceIn('{{$guid}}'));", + "pm.collectionVariables.set('eventRevision',pm.variables.get('requestRevision'));", + "pm.collectionVariables.set('eventBody',pm.variables.replaceIn(pm.request.body.raw));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 202', () => pm.response.to.have.status(202));", + "if (pm.response.code === 202) {", + "pm.collectionVariables.set('executionId',pm.response.json().executionId);", + "}" + ] + } + } + ] + }, + { + "name": "Repetir ÚLTIMO evento (idempotencia)", + "request": { + "method": "POST", + "header": [ + { + "key": "If-Match", + "value": "\"{{eventRevision}}\"" + }, + { + "key": "Idempotency-Key", + "value": "{{eventKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/v1/requests/{{requestId}}/events", + "description": "", + "body": { + "mode": "raw", + "raw": "{{eventBody}}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 202', () => pm.response.to.have.status(202));", + "if (pm.response.code === 202) {", + "pm.test('Misma ejecución',()=>pm.expect(pm.response.json().executionId).to.eql(pm.collectionVariables.get('executionId')));", + "}" + ] + } + } + ] + }, + { + "name": "Consultar ejecución (repetir hasta terminal)", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/v1/executions/{{executionId}}", + "description": "202 solo acredita aceptación. SUCCEEDED confirma terminación correcta; RETRY_WAIT/FAILED/RECONCILIATION_REQUIRED requieren revisar causa. No reenvía ni sondea automáticamente." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "07 — Operación", + "item": [ + { + "name": "Métricas", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/actuator/metrics", + "description": "Requiere EXECUTION_OPERATOR y business_scope administrativo autorizado.", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{selectedToken}}", + "type": "string" + } + ] + } + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.variables.set('selectedToken', pm.variables.get('operatorToken') || pm.variables.get('accessToken'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('HTTP 200', () => pm.response.to.have.status(200));", + "if (pm.response.code === 200) {", + "", + "}" + ] + } + } + ] + } + ] + } + ] +} diff --git a/examples/postman/README.md b/examples/postman/README.md new file mode 100644 index 0000000..e582352 --- /dev/null +++ b/examples/postman/README.md @@ -0,0 +1,64 @@ +# Pruebas desde Postman + +Importar `Orquestador-NMP.postman_collection.json` con **Import > Files**. +La variable de colección `baseUrl` ya apunta a `http://server02:8787`. +Ejecutar solicitudes individualmente; no ejecutar toda la colección con Runner. +Publicar, activar, crear solicitudes y enviar eventos modifican datos. + +## Primera prueba + +1. Ejecutar la carpeta **01**: salud/readiness/liveness deben devolver 200 UP; + la petición sin token debe devolver 401. +2. En Variables de la colección, colocar un access token de Keycloak vigente en + `accessToken`, sin el prefijo Bearer. Guardarlo solo localmente; no compartir + una exportación con tokens. Debe tener el issuer y audiencia configurados, + `sub`, expiración y el claim `business_scope` no vacío. +3. Para administración se pueden configurar `editorToken`, `publisherToken` y + `adminToken` por separado. Si están vacíos se usa `accessToken`. Roles: + FLOW_EDITOR para crear/simular; FLOW_PUBLISHER para publicar; FLOW_ADMIN para + activar. El business_scope debe estar en ADMIN_BUSINESS_SCOPES. + +## Simulación sin llamadas a proveedores + +- Requiere FLOW_ADMIN_ENABLED=true, credenciales DB de runtime restringidas y + migración separadas, y catálogo compatible con + `examples/integrations/publication-catalog.json`. El catálogo vacío inicial + de `config` no contiene los contratos de la demo. +- Ejecutar **02 / Crear borrador** una vez: el JSON de crédito v1 está incluido. + La respuesta guarda `flowId` y `flowRevision`. Si ya existe ese flujo, usar + su ID conocido en las variables. Validar y revisar el informe, incluso con 200. +- Ejecutar las cuatro peticiones de **03**: score 71 lleva a PASO_N; 70 y 61 a + PASO_M; 60 a REVISION_MANUAL. La pestaña Test Results comprueba estos resultados. + No requiere publicar/activar, solicitudes, documentos ni rutas de proveedores. + +## Recorrido persistente + +Solo en entorno de demo con proveedores mock configurados. La colección no +levanta mocks ni configura Keycloak, roles DB, contratos o tipos de solicitud. + +1. Habilitar REQUEST_API_ENABLED y WORKER_ENABLED con el runtime DB restringido. + Configurar rutas HTTP de proveedores de demo. El tipo CREDITO debe existir; + no hay endpoint para crearlo ni para consultar su revisión. Obtener su + revisión actual del administrador y asignarla a `typeRevision` (0 solo si + es nuevo). Mantener `typeKey=CREDITO` para este ejemplo. +2. Publicar y activar mediante **04**, únicamente tras validar sin errores. +3. **05 / Crear NUEVA solicitud** genera una clave de idempotencia y guarda ID + y revisión. La petición siguiente reutiliza clave/cuerpo y verifica el mismo + ID; no cambiar el usuario, tipo ni cuerpo entre ambas. +4. Consultar eventos disponibles. En **06** enviar solo el evento que corresponda + al estado actual. Cada envío nuevo conserva clave, cuerpo y revisión para + permitir el replay del último evento. No editar esas variables para el replay. +5. Consultar ejecución hasta SUCCEEDED; después consultar solicitud y eventos + disponibles para refrescar la revisión antes del siguiente evento. HTTP 202 + acredita aceptación, no terminación. No hay sondeo automático. +6. Para registrar documentos, asignar `documentId` de un documento sintético + existente en el proveedor mock y perteneciente a la solicitud. Usar el documento + correspondiente a cada tarea. No subir documentos personales ni conectar + un buró real para esta demo. +7. Consultar historial. Métricas requiere EXECUTION_OPERATOR y ámbito administrativo; + se puede configurar `operatorToken` o reutilizar accessToken. + +401: revisar token/issuer/audiencia/expiración. 403: revisar rol y ámbito. +404: revisar flags, ID y propiedad del recurso. 412: refrescar revisión. +422: revisar cuerpo, contrato y catálogo. Las respuestas incluyen correlationId +para contrastar con los logs. La colección no sustituye la suite integral T16. diff --git a/specs/DECISIONS.md b/specs/DECISIONS.md index 29cd5b8..aa4958c 100644 --- a/specs/DECISIONS.md +++ b/specs/DECISIONS.md @@ -516,3 +516,19 @@ Se solicitó un motor autorizado mientras se completa verificación con orquesta Una corrida externa aprobada no se rotula Docker ni satisface su criterio: T16 no puede declararse DONE hasta verificar Testcontainers o una modificación explícita del criterio por el usuario. CI configurado tampoco equivale a CI ejecutado. + +## ADR-021 — Proveedores de demo configurables por HTTP — 2026-10-29 + +Solicitud explícita del usuario: servicio de prueba con respuestas configurables manualmente. Se entrega una extensión de demo autorizada, sin declarar T16/T17 completos ni desplegar remotamente. + +Servicio independiente Java 21 con HttpServer/HttpsServer del JDK, sin otro stack de aplicación ni dependencias nuevas. PUT administrativo protegido por token configura código, demora y cuerpo literal; GET consulta configuración/contador. Respuestas globales por endpoint, persistidas por reemplazo atómico en volumen, snapshot por llamada; no se implementan reglas por payload, perfiles separados, secuencias ni deduplicación de efectos. Es suficiente para los contratos READ_ONLY de los ejemplos. Se permiten respuestas malformadas deliberadamente para pruebas negativas. + +Alternativas: solo fixtures de simulate no prueban worker/HTTP; un mock con motor de reglas externo añade dependencias; modificar HttpRoutes para permitir HTTP por hostname debilitaría la política existente. Se conserva HTTPS y se genera certificado de demo por instalación, con SAN explícitos. Override Compose añade confianza del certificado junto a CA públicas del JRE, catálogo/rutas de ejemplo y dependencia por healthcheck. No se modifica seguridad JWT, semántica de versiones/idempotencia, catálogo montado original ni el Compose base. La colección Postman modifica únicamente el mock hasta que el usuario aplique el override al orquestador. + +Límites: no proveedor real, almacenamiento documental ni antivirus; metadatos manuales deben corresponder a la solicitud. Certificado de 365 días y truststore solo de demo; CA privadas/opciones JVM preexistentes requieren incorporarse expresamente. Contadores no persistentes; cuerpos y tokens no se registran. Docker/arranque remoto y recorrido completo permanecen pendientes; pruebas HTTPS locales documentadas en E29. + +## ADR-022 — HTTP sin certificados para la POC — 2026-10-29 + +El usuario solicita evitar certificados para la POC; sustituye la decisión TLS del mock en ADR-021. MockServer, healthcheck, rutas y Postman pasan a HTTP. Se eliminan generación de certificados, mounts de claves/truststore y JAVA_TOOL_OPTIONS del override; las respuestas persistidas se mantienen y no se borran volúmenes anteriores. + +El override activa el perfil explícito demo-mock junto al perfil DB. HttpRoutes permite HTTP hacia providers-mock:8788 únicamente con ese perfil; el requisito HTTPS normal y la excepción local/test para loopback se conservan. Se mantiene el token de administración del mock y la seguridad JWT del orquestador. No se habilita HTTP a otros hosts ni puertos por el perfil demo. La comunicación de la POC no está cifrada. No se modifica idempotencia ni versiones. Validación en E30; Docker sigue pendiente. diff --git a/specs/IMPLEMENTATION_PLAN.md b/specs/IMPLEMENTATION_PLAN.md index 7750b12..e748225 100644 --- a/specs/IMPLEMENTATION_PLAN.md +++ b/specs/IMPLEMENTATION_PLAN.md @@ -274,3 +274,7 @@ Antes de codificar, revisar criterios y prueba pertinente. Al cerrar, registrar T16 vuelve a ejecutar la matriz completa; pruebas unitarias no sustituyen PostgreSQL real. La integración real con proveedores permanece pendiente si faltan contrato/credenciales, aunque los mocks pasen. Seguimiento T16 (2026-10-28, E26): corregidas las entradas del build Docker para incluir ejemplos de flujos/integraciones y los dos archivos de configuración requeridos. Package aislado: 326 pruebas aprobadas. Pendientes build Docker real y suite Testcontainers para el cierre. + +Extensión autorizada 2026-10-29: servicio de proveedores mock configurable manualmente (ADR-021), Compose opcional y colección Postman. Entregable relacionado con demos de T16/T17; no adelanta el cierre de esas tareas. Criterios: configuración HTTP de status/cuerpo/demora, persistencia tras reinicio, HTTPS y token administrativo verificados; despliegue Docker separado de la evidencia local. Ver E29 en PROGRESS. + +Actualización 2026-10-29 (ADR-022): por solicitud del usuario, el mock de la POC usa HTTP sin certificados. Perfil demo-mock limita la excepción a providers-mock:8788. Sustituye el criterio HTTPS de la extensión anterior; se conservan configuración, persistencia y token administrativo. T16/T17 mantienen su estado. diff --git a/specs/PROGRESS.md b/specs/PROGRESS.md index 5fc3af1..baa0efd 100644 --- a/specs/PROGRESS.md +++ b/specs/PROGRESS.md @@ -447,3 +447,25 @@ Con la configuración del repositorio, readiness incluye PostgreSQL. Esta prueba - Compose conecta el servicio orquestador a la red externa existente `microsipNet`, con nombre explícito para evitar prefijo de proyecto. - Verificación: parseo YAML con PyYAML y comprobaciones de asociación del servicio, nombre y external=true; aprobado. `git diff --check` aprobado. - No se ejecutó despliegue ni docker compose config: motor Docker no disponible localmente. La red debe existir en el host de despliegue. T16 conserva su bloqueo de verificación integral. + +## E28 — Colección Postman solicitada — 2026-10-28 + +- Archivos: examples/postman/Orquestador-NMP.postman_collection.json y README.md. +- 24 solicitudes con baseUrl http://server02:8787: salud, rechazo sin token, borrador/validación, cuatro simulaciones de scoring, publicación/activación, solicitudes/idempotencia, eventos/replay/consulta, historial y métricas. Tokens vacíos, captura de IDs/revisiones y claves nuevas por operación. +- Verificación local: parseo JSON, 24 solicitudes, cuerpos JSON y flujo embebido idéntico a credito-v1.json; sintaxis JavaScript de scripts comprobada con Node. No se enviaron peticiones ni se ejecutó importación en Postman; autenticación real requiere token y configuración del usuario. No cierra T16. + +## E29 — Servicio de proveedores mock configurable — 2026-10-29 + +- Solicitud: configurar manualmente las respuestas de servicios de consulta. Entregado demo/providers-mock (servidor Java 21, Dockerfile, arranque TLS, rutas, README, colección Postman de 20 peticiones y pruebas) y compose.mock.yaml opcional. ADR-021 registra alcance/alternativas. Compose base y configuración original sin cambios. +- PUT administrativo por endpoint modifica status, delayMs y cuerpo literal; token requerido, tamaño acotado y persistencia atómica. GET informa configuración y contador. Consulta cliente, buró, validación y metadatos documentales con respuestas sintéticas. Certificados separados de respuestas, validación HTTPS conservada y healthcheck Java previo al arranque del orquestador. +- Comando: con JAVA_HOME=.tools/jdk21/jdk-21.0.12.1+1, `python demo/providers-mock/test_mock.py`. Corrida final: 4 tests, 70.248 s, OK. Servidor HTTPS real en loopback, certificado temporal confiado: autorización/404/405/límites, encontrado/no encontrado, 503/demora, reinicio/persistencia, healthcheck JVM, metadatos configurables, scoring 71/70/61/60, INVALID y cuerpo malformado. Primera corrida de 3 tests también aprobada; ampliada para cubrir healthcheck Java y metadatos. +- Checks adicionales: parseo YAML con PyYAML; seis rutas conservan contratos/perfiles/presupuestos del ejemplo cambiando solo URL/tokenEnvironment; JSON de 20 peticiones y cuerpos válido; scripts Postman compilados con Node; git diff --check aprobado. No se ejecutó importación gráfica en Postman. +- Docker sigue sin ejecutable disponible aquí. No se construyó imagen ni se ejecutó Compose, importación de certificados en el contenedor, recorrido remoto ni proveedor real. No se tocaron PostgreSQL ni server02. T16 conserva BLOCKED; T17 no se declara cerrado. Siguiente: usuario configura token/puerto, levanta mock con override, importa CA/colección y aplica override al orquestador de demo según README. + +## E30 — POC sin certificados — 2026-10-29 + +- Sustituye TLS del mock descrito en E29 por petición explícita. MockServer y healthcheck HTTP; Dockerfile sin keytool/entrypoint de certificados. Compose opcional conserva solo mock-data y activa demo-mock. Rutas y Postman usan http://providers-mock:8788 y http://server02:8788. README actualizado para recreación y uso sin CA. +- HttpRoutes/IntegrationConfiguration habilitan HTTP de providers-mock:8788 solo bajo demo-mock. Constructores/cargadores previos conservan denegación; HTTPS y excepción loopback local/test sin cambios. ADR-022. +- `mvnw.cmd --batch-mode --no-transfer-progress test -Dtest=HttpIntegrationTest`: BUILD SUCCESS, 24 pruebas, cero fallos/errores/omisiones, 40.400 s (17:56:56 -06:00). Incluye rechazo del host mock sin habilitación, aceptación explícita y rechazo de otros hosts/puertos. Log target/mock-http-maven.log. +- `python demo/providers-mock/test_mock.py` con JDK 21: 4 tests OK en 64.015 s; HTTP real, healthcheck Java, configuración, persistencia, errores/demora y metadatos. Parseo de Compose, rutas y colección Postman aprobado; sin mounts de certificados ni JAVA_TOOL_OPTIONS en override. git diff --check aprobado. +- No se ejecutó Docker ni despliegue en server02. T16 sigue BLOCKED, T17 PENDING. Aplicar `docker compose -f compose.yaml -f compose.mock.yaml up -d --build` y reimportar colección del mock; la reconstrucción del orquestador es necesaria para la nueva excepción explícita. diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java index 816e522..10fbacf 100644 --- a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java @@ -19,6 +19,9 @@ public final class HttpRoutes { } private final Map routes; public HttpRoutes(Collection routes,PublicationCatalog catalog,boolean local,long leaseSeconds){ + this(routes,catalog,local,leaseSeconds,false); + } + public HttpRoutes(Collection routes,PublicationCatalog catalog,boolean local,long leaseSeconds,boolean demoMock){ var copy=new HashMap(); for(var route:routes){ var key=route.key();Checks.key(key.integration());Checks.text(key.version(),100); @@ -31,8 +34,9 @@ public final class HttpRoutes { rejectReferences(new FlowJsonCodec().tree(contract.output().value())); URI uri=route.uri(); if(uri.getHost()==null||uri.getRawUserInfo()!=null||uri.getRawQuery()!=null||uri.getRawFragment()!=null - ||!("https".equals(uri.getScheme())||(local&&"http".equals(uri.getScheme())&&Set.of("127.0.0.1","localhost","[::1]","::1").contains(uri.getHost())))) - throw new IllegalArgumentException("Route requires HTTPS (loopback HTTP only in local/test)"); + ||!("https".equals(uri.getScheme())||(local&&"http".equals(uri.getScheme())&&Set.of("127.0.0.1","localhost","[::1]","::1").contains(uri.getHost())) + ||(demoMock&&"http".equals(uri.getScheme())&&"providers-mock".equals(uri.getHost())&&uri.getPort()==8788))) + throw new IllegalArgumentException("Route requires HTTPS (loopback HTTP in local/test; providers-mock:8788 only in demo-mock)"); if(route.connectMs()<1||route.timeoutMs()30000||route.timeoutMs()>=leaseSeconds*1000 ||route.maxBytes()<1||route.maxBytes()>262144)throw new IllegalArgumentException("Invalid route budget"); if(!route.tokenEnvironment().isEmpty()&&!route.tokenEnvironment().matches("[A-Z][A-Z0-9_]{0,99}"))throw new IllegalArgumentException("Invalid secret reference"); @@ -48,6 +52,9 @@ public final class HttpRoutes { if(node.isContainerNode())node.forEach(HttpRoutes::rejectReferences); } public static HttpRoutes load(Path path,PublicationCatalog catalog,FlowJsonCodec codec,boolean local,long lease){ + return load(path,catalog,codec,local,lease,false); + } + public static HttpRoutes load(Path path,PublicationCatalog catalog,FlowJsonCodec codec,boolean local,long lease,boolean demoMock){ try(var in=Files.newInputStream(path)){ byte[] bytes=in.readNBytes(FlowJsonCodec.MAX_BYTES+1);if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new IllegalArgumentException("Routes too large"); var n=new RequestJson(codec).parse(new String(bytes,java.nio.charset.StandardCharsets.UTF_8)); @@ -62,7 +69,7 @@ public final class HttpRoutes { URI.create(string(r.get("url"))),string(r.get("tokenEnvironment")),integer(r.get("connectMs")),integer(r.get("timeoutMs")),integer(r.get("maxBytes")),mappings, notFound.isNull()?Optional.empty():Optional.of((ObjectValue)codec.value(notFound)))); } - return new HttpRoutes(routes,catalog,local,lease); + return new HttpRoutes(routes,catalog,local,lease,demoMock); }catch(Exception e){throw new IllegalStateException("Invalid HTTP route configuration");} } private static int integer(JsonNode n){if(!n.isIntegralNumber()||!n.canConvertToInt())throw new IllegalArgumentException("Integer required");return n.intValue();} diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java index bb99d6f..a5b2731 100644 --- a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java @@ -18,7 +18,8 @@ public class IntegrationConfiguration { @Bean HttpRoutes httpRoutes(PublicationCatalog catalog,FlowJsonCodec codec,Environment environment, @Value("${orchestrator.integrations.routes-path:config/integration-routes.json}")String path,@Value("${orchestrator.worker.lease-seconds:60}")long lease){ boolean local=Arrays.stream(environment.getActiveProfiles()).anyMatch(p->p.equals("local")||p.equals("test")); - return HttpRoutes.load(Path.of(path),catalog,codec,local,lease); + boolean demoMock=Arrays.asList(environment.getActiveProfiles()).contains("demo-mock"); + return HttpRoutes.load(Path.of(path),catalog,codec,local,lease,demoMock); } @Bean HttpIntegrationHandler httpIntegrationHandler(PublicationCatalog catalog,HttpRoutes routes,BoundedHttp http,FlowJsonCodec codec,io.micrometer.core.instrument.MeterRegistry meters){return new HttpIntegrationHandler(catalog,routes,http,codec,System::getenv,meters);} @Bean RegisterDocumentHandler registerDocumentHandler(PublicationCatalog catalog,HttpIntegrationHandler integrations){return new RegisterDocumentHandler(catalog,integrations);} diff --git a/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java b/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java index f40b3b7..ad43ce7 100644 --- a/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java +++ b/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java @@ -14,6 +14,16 @@ import static com.organizacion.orchestrator.HttpProviderFixture.*; import static org.junit.jupiter.api.Assertions.*; class HttpIntegrationTest { + @Test void demoHttpIsRestrictedToExplicitProfileAndMockAddress() { + var catalog=exampleCatalog(); + // Keep this fixture self-contained for the normal Docker build. + for(String url:List.of("http://providers-mock:8788/clientes_consulta","http://other:8788/clientes_consulta","http://providers-mock:8080/clientes_consulta")) { + var route=new HttpRoutes.Route(new HttpRoutes.Key("CLIENTES_CONSULTA","1","",""),URI.create(url),"",1000,5000,262144,Map.of(),Optional.empty()); + assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(route),catalog,false,60)); + if(url.contains("providers-mock:8788")) assertDoesNotThrow(()->new HttpRoutes(List.of(route),catalog,false,60,true)); + else assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(route),catalog,false,60,true)); + } + } @Test void workerConfigurationBuildsRegistryWithoutAdministration(){ try(var context=new org.springframework.context.annotation.AnnotationConfigApplicationContext()){ context.getEnvironment().getPropertySources().addFirst(new org.springframework.core.env.MapPropertySource("test",Map.of("orchestrator.worker.enabled","true","orchestrator.admin.enabled","false")));