diff --git a/.env.example b/.env.example index bc13a16..bf0d769 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,32 @@ DB_URL=jdbc:postgresql://server02:30432/orquestador DB_USERNAME=pgadmin DB_PASSWORD= +# Para separar migraciones y runtime, configurar un rol propietario distinto: +# DB_MIGRATION_USERNAME=orchestrator_migrator +# DB_MIGRATION_PASSWORD= SPRING_PROFILES_ACTIVE=postgres96 JWT_ISSUER_URI=http://keycloakic.ddns.net/auth/realms/controlms JWT_JWK_SET_URI=http://keycloakic.ddns.net/auth/realms/controlms/protocol/openid-connect/certs JWT_AUDIENCE=orquestador-nmp APP_BIND_ADDRESS=127.0.0.1 APP_PORT=8080 + +# T07: claim de negocio obligatorio para JWT autenticados; no es el scope OAuth. +JWT_BUSINESS_SCOPE_CLAIM=business_scope +# Habilitar solo con DB_USERNAME restringido y DB_MIGRATION_USERNAME separado. +# pgadmin no puede usarse como runtime cuando la administracion esta habilitada. +FLOW_ADMIN_ENABLED=false +# API de solicitudes independiente de administracion; exige el mismo runtime DB restringido. +REQUEST_API_ENABLED=false +WORKER_ENABLED=false +INTEGRATION_ROUTES_PATH=/app/config/integration-routes.json +WORKER_CONCURRENCY=10 +WORKER_BATCH=10 +WORKER_POLL_MS=500 +WORKER_LEASE_SECONDS=60 +WORKER_RENEW_SECONDS=15 +WORKER_SHUTDOWN_SECONDS=20 +# Lista de ambitos autorizados para administrar el catalogo global; vacia deniega todo. +ADMIN_BUSINESS_SCOPES= +# Ruta dentro del contenedor; compose monta ./config como solo lectura. +FLOW_CATALOG_PATH=/app/config/publication-catalog.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 50536eb..3b14af6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,6 +7,7 @@ permissions: jobs: verify: runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -14,8 +15,14 @@ jobs: distribution: temurin java-version: '21' cache: maven - - name: Verify + - name: Docker available for Testcontainers + run: docker info + - name: Verify acceptance checker + run: python3 -m unittest discover -s scripts -p 'test_*.py' + - name: Verify with isolated PostgreSQL Testcontainers run: sh ./mvnw --batch-mode --no-transfer-progress clean verify + - name: Require A01-A20 without skipped tests + run: python3 scripts/verify_acceptance.py --require-testcontainers - name: Test reports if: always() uses: actions/upload-artifact@v4 @@ -24,4 +31,7 @@ jobs: path: | target/surefire-reports/ target/failsafe-reports/ - if-no-files-found: ignore + target/acceptance-report.json + specs/acceptance-cases.json + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 89784a3..573c041 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.env *.log /.tools/ +__pycache__/ diff --git a/README.md b/README.md index c80852c..544597b 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,11 @@ Salud: `http://localhost:8080/actuator/health/readiness`. El puerto publicado se - [Arquitectura](specs/ARCHITECTURE.md): paquetes y fronteras. - [Decisiones](specs/DECISIONS.md): versiones y motivos. +- [Administración interna T06](specs/FLOW_PUBLICATION.md): validación, publicación, catálogo y transacciones; REST seguro pendiente de T07. - [Plan T01–T17](specs/IMPLEMENTATION_PLAN.md): dependencias, subtareas y criterios de cierre. - [Seguimiento](specs/PROGRESS.md): estado real, verificaciones y siguiente paso. -Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 incorpora un smoke de despliegue con cinco comprobaciones; T03 incorporará Testcontainers. CI ejecuta `clean verify` sin acceder a servidores privados. Para verificar explícitamente un servicio iniciado, usar JDK 21 y ejecutar: +Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 incorpora un smoke de despliegue con cinco comprobaciones; T03 incorpora Testcontainers PostgreSQL 9.6.24 y requiere Docker para verify. CI ejecuta `clean verify` sin acceder a servidores privados. Para verificar explícitamente un servicio iniciado, usar JDK 21 y ejecutar: ```powershell .\mvnw.cmd clean verify -Pdeployment-smoke '-Dsmoke.baseUrl=http://server02:8787' @@ -41,10 +42,104 @@ Surefire ejecuta `*Test` en `test`; Failsafe ejecuta `*IT` en `verify`. T01 inco Este perfil comprueba salud, readiness, liveness y rechazo de solicitudes sin token/con token malformado. No contiene todavía pruebas de negocio ni de JWT válido. -`clean verify` valida los contratos y sus ejemplos sin servicios externos. Consultar [convenciones REST](contracts/README.md), [semántica del DSL](schemas/README.md) y [fixtures T02](examples/contracts/README.md). Los ejemplos de `examples/http/contracts.http` son contratos objetivo, no endpoints implementados. Los flujos de crédito completos corresponden a T14. +`test` valida los contratos y sus ejemplos sin servicios externos; `clean verify` agrega migraciones e integración PostgreSQL. Sin Docker local, usar el perfil migration-tests-external descrito en RUNBOOK. Consultar [convenciones REST](contracts/README.md), [semántica del DSL](schemas/README.md) y [fixtures T02](examples/contracts/README.md). Los ejemplos de `examples/http/contracts.http` son contratos objetivo, no endpoints implementados. Los flujos de crédito completos corresponden a T14. -El Dockerfile incluye contratos/schemas/fixtures en la etapa de construcción para ejecutar las mismas pruebas y ejecuta con JRE 21 como usuario no root. Compose no administra PostgreSQL ni sus volúmenes. Migraciones, mocks y roles de mínimo privilegio siguen pendientes de T03/T11/T07. +El Dockerfile incluye contratos/schemas/fixtures en la etapa de construcción para ejecutar las mismas pruebas y ejecuta con JRE 21 como usuario no root. Compose no administra PostgreSQL ni sus volúmenes. T03 incluye migraciones y permisos PostgreSQL; mocks y autorización funcional siguen pendientes de T11/T07. ## Límites funcionales previstos Nuevos umbrales, destinos y pasos con capacidades existentes se publicarán como configuración. Una nueva capacidad de validación puede requerir un adapter; un nuevo tipo de tarea puede requerir soporte del front. No incluye UI administrativa, Kafka, Drools ni migración automática de instancias. + +## Seguridad y administración (T07) + +La administración REST requiere habilitación explícita y credenciales PostgreSQL +separadas de migración/ejecución. Ver [SECURITY.md](specs/SECURITY.md) y las variables +nuevas de `.env.example`. Compose conserva únicamente el servicio y monta `config/` +como solo lectura; PostgreSQL sigue externo. No se ha desplegado esta versión. + +## Solicitudes (T08) + +Implementados POST y GET de solicitudes, con versión fijada, idempotencia durable, +validación de datos y autorización. Habilitar REQUEST_API_ENABLED=true únicamente +con runtime DB restringido; administración puede permanecer apagada. +[Configuración y semántica](specs/REQUESTS.md); [ejemplo HTTP](examples/http/requests.http). +No se ha desplegado esta versión. Eventos y worker continúan pendientes. + +## Eventos (T09) + +POST /requests/{id}/events acepta comandos idempotentes y retorna 202/Location. +Ejecución PENDING, revisión y auditoría se confirman juntas. Ver +[EVENTS.md](specs/EVENTS.md) y [ejemplo](examples/http/events.http). +El worker y GET de ejecución continúan pendientes en T10: 202 no indica que se +haya procesado la acción. No se desplegó esta versión. + +## Worker y consulta de ejecución (T10) + +GET /api/v1/executions/{id} ya permite seguimiento autorizado. WORKER_ENABLED=true +activa procesamiento durable con leases y checkpoints; defaults y límites en +[WORKER.md](specs/WORKER.md). V3 conserva clasificación de efectos de acciones. +Los adapters T11 requieren contratos y rutas configurados; los registros por +defecto están vacíos. Flujos sin acciones pueden concluir. +No se ha desplegado esta versión ni se han invocado proveedores reales. + +## Integraciones (T11) + +INVOKE_INTEGRATION ejecuta POST JSON con contratos/perfiles fijados y límites de +tiempo y tamaño. REGISTER_DOCUMENT verifica metadatos y registra referencias de +forma atómica. NO_OP se limita a simulación. +[Configuración y límites](specs/INTEGRATIONS.md); +[ejemplos de contratos](examples/integrations/publication-catalog.json) y +[rutas](examples/integrations/integration-routes.example.json). +Los ejemplos usan dominios .invalid y datos sintéticos; requieren adaptación al +proveedor real. Compose sigue levantando únicamente el servicio con PostgreSQL +externo. + +## Reintentos y conciliación (T12) + +El worker programa reintentos durables para fallos transitorios seguros, con +backoff, jitter y Retry-After acotados. Los efectos inciertos mantienen bloqueada +la solicitud hasta una resolución con evidencia por EXECUTION_OPERATOR. +[Operación y límites](specs/RECONCILIATION.md); +[ejemplos HTTP](examples/http/reconciliation.http). +La resolución requiere administración habilitada y ámbito autorizado; no permite +elegir manualmente el estado de negocio. La evidencia es verificada por el operador, +sin consulta automática a un proveedor real. + +## Eventos disponibles y documentos (T13) + +GET /api/v1/requests/{id}/available-events devuelve eventos, schemas y metadatos +FORM/DOCUMENT_UPLOAD de la versión fijada; oculta tareas con ejecución activa. +No incluye desarrollar una interfaz gráfica. El worker verifica también los +límites documentales de la tarea publicada. +[Semántica y pruebas](specs/AVAILABLE_EVENTS.md); +[ejemplo HTTP](examples/http/available-events.http). + +## Flujos de demostración (T14) + +[Crédito v1/v2 y compra](examples/flows/README.md) incluyen definiciones completas, +catálogo y ejemplos HTTP. DemoFlowsIT publica y recorre los archivos exactos usando +API, worker, PostgreSQL de pruebas y proveedores HTTP simulados. La v2 incorpora +licencia por configuración; las solicitudes existentes conservan v1. No hay front +ni cobro real. La integración de proveedores reales requiere validación separada. + +## Auditoría y observabilidad (T15) + +GET /api/v1/requests/{id}/history consulta el historial del propietario y ámbito +con filtros y cursor. Actuator metrics requiere EXECUTION_OPERATOR y un ámbito +incluido en ADMIN_BUSINESS_SCOPES. Incluye estados de cola, edad del pendiente, +leases vencidos, resultados HTTP y latencia de integraciones sin etiquetas por UUID. +[Operación y límites](specs/OBSERVABILITY.md); +[ejemplos HTTP](examples/http/history-operations.http). +WORKER_SHUTDOWN_SECONDS controla el drenaje (1–20 s, predeterminado 20). + +## Verificación integral y simulación (T16) + +[Guía y matriz ejecutable A01–A20](specs/ACCEPTANCE.md). Después de clean verify, +`python scripts/verify_acceptance.py --require-testcontainers` exige evidencia de +cada caso y ningún test omitido. El perfil externo identifica explícitamente su +entorno y no pasa esa puerta Docker. CI archiva reportes y matriz. + +POST /api/v1/admin/flows/{id}/simulate evalúa fixtures con FLOW_EDITOR y ámbito +administrativo autorizado. Usa la proyección compartida con el worker, sin proveedores +ni escrituras de negocio. [Semántica](specs/SIMULATION.md) y +[ejemplo HTTP](examples/http/simulate.http). diff --git a/compose.yaml b/compose.yaml index 1f7fc09..b663987 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,12 +9,29 @@ services: DB_URL: ${DB_URL:?Configura DB_URL en .env} DB_USERNAME: ${DB_USERNAME:?Configura DB_USERNAME en .env} DB_PASSWORD: ${DB_PASSWORD:?Configura DB_PASSWORD en .env} + DB_MIGRATION_USERNAME: ${DB_MIGRATION_USERNAME:-${DB_USERNAME}} + DB_MIGRATION_PASSWORD: ${DB_MIGRATION_PASSWORD:-${DB_PASSWORD}} JWT_ISSUER_URI: ${JWT_ISSUER_URI:?Configura JWT_ISSUER_URI en .env} JWT_JWK_SET_URI: ${JWT_JWK_SET_URI:?Configura JWT_JWK_SET_URI en .env} JWT_AUDIENCE: ${JWT_AUDIENCE:?Configura JWT_AUDIENCE en .env} + JWT_BUSINESS_SCOPE_CLAIM: ${JWT_BUSINESS_SCOPE_CLAIM:-business_scope} + FLOW_ADMIN_ENABLED: ${FLOW_ADMIN_ENABLED:-false} + REQUEST_API_ENABLED: ${REQUEST_API_ENABLED:-false} + WORKER_ENABLED: ${WORKER_ENABLED:-false} + INTEGRATION_ROUTES_PATH: ${INTEGRATION_ROUTES_PATH:-/app/config/integration-routes.json} + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-10} + WORKER_BATCH: ${WORKER_BATCH:-10} + WORKER_POLL_MS: ${WORKER_POLL_MS:-500} + WORKER_LEASE_SECONDS: ${WORKER_LEASE_SECONDS:-60} + WORKER_RENEW_SECONDS: ${WORKER_RENEW_SECONDS:-15} + WORKER_SHUTDOWN_SECONDS: ${WORKER_SHUTDOWN_SECONDS:-20} + ADMIN_BUSINESS_SCOPES: ${ADMIN_BUSINESS_SCOPES:-} + FLOW_CATALOG_PATH: ${FLOW_CATALOG_PATH:-/app/config/publication-catalog.json} SERVER_PORT: "8080" ports: - "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8080}:8080" extra_hosts: - "host.docker.internal:host-gateway" + volumes: + - ./config:/app/config:ro stop_grace_period: 40s diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..329e420 --- /dev/null +++ b/config/README.md @@ -0,0 +1,12 @@ +# Catálogo confiable + +publication-catalog.json configura la publicación T06/T07. Compose monta este +directorio en /app/config con permisos de solo lectura. El ejemplo permite roles +ANALISTA y no registra capacidades externas. No contiene secretos ni mocks. + +Campos raíz obligatorios: roles (array), handlers, integrations y documents (objetos). +PublicationCatalogLoader valida campos exactos y rechaza claves duplicadas. +Las estructuras tipadas y las reglas de publicación se describen en +../specs/FLOW_PUBLICATION.md. El registro de capacidades y contratos de proveedor +debe acompañar a handlers reales en T11; declararlos aquí no implementa ejecución. +Para habilitar las rutas administrativas ver ../specs/SECURITY.md. diff --git a/config/integration-routes.json b/config/integration-routes.json new file mode 100644 index 0000000..60cd85d --- /dev/null +++ b/config/integration-routes.json @@ -0,0 +1 @@ +{"routes":[]} diff --git a/config/publication-catalog.json b/config/publication-catalog.json new file mode 100644 index 0000000..2457a69 --- /dev/null +++ b/config/publication-catalog.json @@ -0,0 +1,6 @@ +{ + "roles": ["ANALISTA"], + "handlers": {}, + "integrations": {}, + "documents": {} +} diff --git a/contracts/README.md b/contracts/README.md index 40380cc..bcc1641 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -32,3 +32,30 @@ Conciliación sólo permite RESUME con resultados verificados por acción o CLOS Con JDK 21: `./mvnw clean verify` (Windows: `.\mvnw.cmd clean verify`). ContractValidationTest comprueba el schema oficial OpenAPI 3.1, parseo Swagger, resolución local de referencias, meta-schema 2020-12, fixtures positivos/negativos, ejemplos embebidos y cabeceras/rutas obligatorias. Los validadores tienen scope test; no agregan dependencias Jackson 2 al runtime. Swagger Parser 2.1.35 se usa sin expansión recursiva (`resolve=false`): expandir el AST produjo un error de profundidad. Las referencias se verifican explícitamente y networknt valida los ejemplos con los schemas reales; no se ignoran referencias rotas. Las pruebas normales no contactan server02, PostgreSQL, Keycloak ni proveedores. No sustituyen T05/T06/T07/T16. + +## Estado de implementación T07 + +Crear, editar, validar, publicar, activar y retirar flujos tienen controlador REST +protegido. Simulación continúa pendiente (T14), conciliación en T12 y rutas de +negocio en T08–T15. Configuración, permisos DB y política por recurso: +[SECURITY.md](../specs/SECURITY.md). SecurityHttpIT verifica JWT RSA mediante JWKS +local, HTTP real y PostgreSQL aislado; no valida aún la configuración real de controlms. + +Desde T06 networknt y Jackson 2 se usan también en infraestructura de runtime para +validar DSL; Swagger Parser permanece solo en test. Esto sustituye la descripción +histórica de todos los validadores como dependencias exclusivamente de test. + +T08 implementa POST /requests y GET /requests/{id}, con JWT, propiedad/ámbito y +ETag. Creación repetida retorna snapshot original; consulta retorna estado actual. +Tipo inexistente 404; no asignable/cambio concurrente 409. Ver +[REQUESTS.md](../specs/REQUESTS.md) para habilitación y protocolo transaccional. + +T09 implementa POST /requests/{id}/events con 202/Location, bloqueo de solicitud, +replay antes de revisión y persistencia atómica. Ver EVENTS.md en specs. +GET /executions/{id} sigue como contrato objetivo y se implementará en T10; +no se debe interpretar el 202 como procesamiento completado. + +T10 implementa GET /executions/{id} con autorización por recurso y ExecutionView, +y worker opcional. Sustituye el estado pendiente descrito en T09. 202 sigue siendo +aceptación; consultar ejecución para resultado y luego refrescar solicitud/ETag. +Ver specs/WORKER.md para efectos, checkpoints, límites y configuración. diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 16d90b5..5ac37ed 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -3,7 +3,7 @@ jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema info: title: Orquestador NMP version: 1.0.0 - description: Contrato objetivo del MVP. Las rutas de negocio aún no están implementadas. JWT y autorización por + description: Contrato objetivo del MVP. Solicitudes, eventos, ejecuciones, conciliación, tareas e historial implementados hasta T15; simulación administrativa implementada en T16. JWT y autorización por recurso obligatorios. Campos opcionales ausentes se omiten; no se usa null salvo en datos configurables. servers: - url: /api/v1 @@ -18,9 +18,9 @@ paths: operationId: createRequest summary: Crear solicitud y fijar flujo activo description: 'Owner y businessScope se derivan del JWT autorizado. Idempotencia durable por actor/scope: repetición - idéntica retorna la misma solicitud (201); otro hash retorna 409. data se valida con initialDataSchema y + idéntica retorna la respuesta original de creación (201, Location y ETag originales), incluso si cambió el flujo activo o avanzó la solicitud; otro hash retorna 409. GET devuelve el estado actual. data se valida con initialDataSchema y dataSchema del flujo. Máximo de datos 256 KiB. No se aceptan estado, propietario o resultados de proveedor - como autoridad del cliente.' + como autoridad del cliente. Tipo inexistente retorna 404; tipo deshabilitado, sin flujo publicado activo o cambiado durante la asignación retorna 409 INVALID_FLOW_STATE. Un 409 por cambio concurrente permite reintentar la misma clave, que no queda reservada.' tags: - Requests parameters: @@ -63,6 +63,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '413': @@ -133,7 +135,7 @@ paths: operationId: availableEvents summary: Consultar tareas y eventos permitidos description: 'Orientativo: el POST revalida roles y guardias. Con ejecución activa, events vacío y blockedByExecutionId - presente. Sólo se exponen causas de bloqueo no sensibles.' + presente. Sólo se exponen causas de bloqueo no sensibles. Filtra por propietario, ámbito y roles; usa la versión fijada incluso retirada. Guardias dependientes de event.data se difieren al POST. Cache-Control no-store, sin ETag; revision sirve para If-Match del evento.' tags: - Requests parameters: @@ -168,7 +170,7 @@ paths: description: 'Una transición por evento; sin HTTP externo en la transacción de aceptación. El hash incluye evento, datos y revisión esperada canónicos. Primero comprobar acceso e idempotencia y después revisión; un replay idéntico devuelve el mismo executionId/status actual incluso si cambió revision. Nueva clave mientras - existe ejecución activa: REQUEST_BUSY. Validar data con schema del evento. 202 no equivale a éxito de negocio.' + existe ejecución activa: REQUEST_BUSY (se comprueba revisión antes de busy). Validar data con schema del evento. Reintentar conservando If-Match original. 202 no equivale a éxito de negocio; GET de ejecución permite seguimiento; el worker se habilita explícitamente con WORKER_ENABLED.' tags: - Requests parameters: @@ -268,7 +270,7 @@ paths: operationId: getHistory summary: Consultar auditoría paginada y filtrada description: Orden ascendente (createdAt,id), cursor opaco asociado a solicitud y filtros. Máximo 100. Omitir - nextCursor al terminar. from inclusivo y to exclusivo; actor/detalle filtrados según autorización. + nextCursor al terminar. from inclusivo y to exclusivo, UTC Z. Propietario y ámbito obligatorios, sin bypass administrativo. Detail se proyecta por lista permitida (IDs de reglas/acciones, razones y códigos); no entrega evidencia ni resultados completos. Cache-Control no-store. Cursor representa posición, no autorización; no promete snapshot entre páginas concurrentes. tags: - Requests parameters: @@ -828,7 +830,7 @@ paths: summary: Simular evaluación pura description: Sin HTTP ni efectos persistentes. requestData/actor/results son fixtures administrativos, nunca comandos de negocio. Evaluar guardia/decisión/updates con resultados suministrados; rechazo de tipos y ambigüedad - como problem 422. NO_OP sólo permitido en simulación. + como problem 422. Body máximo 1 MiB; requestData/event.data/cada resultado máximo 256 KiB. results usa exactamente los resultKey externos de la transición. NO_OP sólo permitido en simulación, toma output de la definición sin fixture externo. Cache-Control no-store. Actor de fixture no sustituye JWT FLOW_EDITOR ni ámbito administrativo. tags: - Administration parameters: @@ -1236,8 +1238,8 @@ paths: summary: Conciliar una ejecución incierta description: Sólo RECONCILIATION_REQUIRED, con acceso por scope. Evidencia referenciada y verificada por operador autorizado; no admite elegir estado destino. RESUME registra resultados verificados y reencola PENDING; - CLOSE_WITHOUT_TRANSITION verifica ausencia/compensación de efectos y cierra FAILED conservando negocio. - Idempotencia durable por ejecución/clave y comando canónico; replay idéntico devuelve misma resolución. + CLOSE_WITHOUT_TRANSITION registra ausencia/compensación verificada por el operador y cierra FAILED conservando negocio. No consulta automáticamente al proveedor. + Idempotencia durable por ejecución/clave y comando canónico; replay exige el mismo sujeto operador y devuelve la misma resolución. Auditoría obligatoria. tags: - Administration @@ -1266,6 +1268,8 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' '413': $ref: '#/components/responses/PayloadTooLarge' '422': diff --git a/examples/flows/README.md b/examples/flows/README.md index cb32445..a6d688b 100644 --- a/examples/flows/README.md +++ b/examples/flows/README.md @@ -1,5 +1,53 @@ -# Flujos pendientes de T14 +# Flujos demostrativos T14 -Entregar credito-v1.json, credito-v2-licencia.json y compra-v1.json completos y publicables. -La versión 2 incorpora licencia antes del buró utilizando capacidades existentes. -No presentar fragmentos como flujos funcionales. +| Archivo | Recorrido | +| --- | --- | +| credito-v1.json | Cliente → INE → estado de cuenta → buró → PASO_N/PASO_M/REVISION_MANUAL | +| credito-v2-licencia.json | Mismo recorrido; agrega licencia antes del buró | +| compra-v1.json | Cliente → confirmación → COMPRA_CONFIRMADA/COMPRA_CANCELADA | + +Compra demuestra un segundo tipo; no cobra ni reserva inventario. Score >70 lleva +a PASO_N; >60 y <=70 a PASO_M; <=60 a REVISION_MANUAL. Score ausente/no numérico +es fallo técnico. INVALID documental conserva tarea y reasonCodes; cliente ausente +conserva CREADA. + +Usar el [catálogo completo](../integrations/publication-catalog.json). Los perfiles +INE, ESTADO_CUENTA y LICENCIA_CONDUCIR de DOCUMENTO_VALIDA están fijados en versión 1. +Máximo documental: 5 MiB; estado de cuenta solo PDF; INE/licencia PDF, JPEG o PNG. +El API recibe documentId; verifica metadata del proveedor, no recibe archivos. +consentimientoId es obligatorio; la validez real debe verificarse con el proveedor. + +## Prueba reproducible + +Con JDK 21 y MIGRATION_TEST_DB_URL/USER/PASSWORD apuntando a una base de pruebas: + +```powershell +./mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external '-Dtest=DemoFlowTest' '-Dit.test=DemoFlowsIT' +``` + +Inicia API con JWT/JWKS local, publica los JSON exactos, activa ambos tipos, crea +solicitudes y ejecuta eventos/worker/SQL. Cuatro proveedores HTTP loopback exclusivos +de test devuelven datos sintéticos. Crea y elimina esquemas/roles temporales. +Cubre coexistencia v1/v2, licencia inválida/válida, score 71/70/61/60, cliente ausente, +score mal formado y ambos finales de compra. No requiere Keycloak ni buró real. + +## Recorrido manual en entorno de demo + +1. Configurar FLOW_CATALOG_PATH con el catálogo de ejemplo, FLOW_ADMIN_ENABLED, + REQUEST_API_ENABLED y WORKER_ENABLED, JWT y ADMIN_BUSINESS_SCOPES según README. + Usar usuario DB runtime restringido y rutas de proveedores de demo confiables. + integration-routes.example.json contiene hosts .invalid, no servicios operativos. +2. Crear tipos con [seed-request-types.sql](seed-request-types.sql) como dueño del + esquema. Ajustar el esquema al configurado; no es una migración automática. +3. [demo-flows.http](../http/demo-flows.http) contiene publicación, activación y + recorridos. Copiar IDs/ETags de respuestas y proporcionar tokens de cada rol. +4. Crear crédito con v1, activar v2 y crear otro. Cada solicitud conserva su flowId; + después del estado de cuenta solo la nueva exige REGISTRAR_LICENCIA. +5. Para cada evento usar If-Match de la consulta actual. Esperar SUCCEEDED antes de + continuar: 202 solo significa aceptado. Reintento de transporte conserva clave; + nueva operación de negocio usa una clave nueva. DocumentId debe existir en el + proveedor documental de demo y pertenecer a la solicitud/ámbito correspondiente. + +No invoca /simulate: recorre la API operativa contra mocks. No modifica Java de +producción, migraciones ni columnas. Proveedores reales pendientes de contrato y +verificación independiente. Docker permanece con un servicio y PostgreSQL externo. diff --git a/examples/flows/compra-v1.json b/examples/flows/compra-v1.json new file mode 100644 index 0000000..4aa42d3 --- /dev/null +++ b/examples/flows/compra-v1.json @@ -0,0 +1,272 @@ +{ + "schemaVersion": 1, + "flowKey": "COMPRA", + "version": 1, + "initialState": "CREADA", + "initialDataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "orderId": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": [ + "client", + "orderId", + "amount" + ], + "additionalProperties": false + }, + "dataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "orderId": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "cliente": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "customerId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "found" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "found": { + "const": true + } + } + }, + "then": { + "required": [ + "customerId" + ] + } + } + ] + }, + "confirmada": { + "type": "boolean" + } + }, + "required": [ + "client", + "orderId", + "amount" + ], + "additionalProperties": false + }, + "states": [ + { + "key": "CREADA", + "terminal": false + }, + { + "key": "CONFIRMACION_PENDIENTE", + "terminal": false + }, + { + "key": "COMPRA_CONFIRMADA", + "terminal": true + }, + { + "key": "COMPRA_CANCELADA", + "terminal": true + } + ], + "events": [ + { + "key": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "key": "CONFIRMAR_COMPRA", + "inputSchema": { + "type": "object", + "properties": { + "confirmada": { + "type": "boolean" + } + }, + "required": [ + "confirmada" + ], + "additionalProperties": false + } + } + ], + "transitions": [ + { + "id": "asignar-cliente", + "from": "CREADA", + "event": "ASIGNAR_CLIENTE", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "cliente", + "type": "INVOKE_INTEGRATION", + "integrationKey": "CLIENTES_CONSULTA", + "contractVersion": "1", + "inputs": { + "client": { + "path": "/request/data/client" + } + }, + "resultKey": "cliente" + } + ], + "decision": "CLIENTE_EXISTE", + "destinations": { + "CLIENTE_ENCONTRADO": "CONFIRMACION_PENDIENTE", + "CLIENTE_NO_ENCONTRADO": "CREADA" + }, + "updates": [ + { + "target": "/cliente", + "value": { + "path": "/results/cliente" + } + } + ] + }, + { + "id": "confirmar-compra", + "from": "CONFIRMACION_PENDIENTE", + "event": "CONFIRMAR_COMPRA", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [], + "decision": "CONFIRMACION", + "destinations": { + "CONFIRMADA": "COMPRA_CONFIRMADA", + "CANCELADA": "COMPRA_CANCELADA" + }, + "updates": [ + { + "target": "/confirmada", + "value": { + "path": "/event/data/confirmada" + } + } + ] + } + ], + "decisions": [ + { + "id": "CLIENTE_EXISTE", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "encontrado", + "when": { + "op": "EQ", + "left": { + "path": "/results/cliente/found" + }, + "right": { + "literal": true + } + }, + "output": "CLIENTE_ENCONTRADO" + } + ], + "defaultOutput": "CLIENTE_NO_ENCONTRADO" + }, + { + "id": "CONFIRMACION", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "confirmada", + "when": { + "op": "EQ", + "left": { + "path": "/event/data/confirmada" + }, + "right": { + "literal": true + } + }, + "output": "CONFIRMADA" + } + ], + "defaultOutput": "CANCELADA" + } + ], + "tasks": [ + { + "state": "CREADA", + "event": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Consultar cliente", + "fieldOrder": [] + } + }, + { + "state": "CONFIRMACION_PENDIENTE", + "event": "CONFIRMAR_COMPRA", + "inputSchema": { + "type": "object", + "properties": { + "confirmada": { + "type": "boolean" + } + }, + "required": [ + "confirmada" + ], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Confirmar o cancelar compra de demostración", + "fieldOrder": [ + "confirmada" + ] + } + } + ] +} diff --git a/examples/flows/credito-v1.json b/examples/flows/credito-v1.json new file mode 100644 index 0000000..fbae350 --- /dev/null +++ b/examples/flows/credito-v1.json @@ -0,0 +1,622 @@ +{ + "schemaVersion": 1, + "flowKey": "CREDITO", + "version": 1, + "initialState": "CREADA", + "initialDataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "consentimientoId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "client", + "consentimientoId" + ], + "additionalProperties": false + }, + "dataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "consentimientoId": { + "type": "string", + "minLength": 1 + }, + "cliente": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "customerId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "found" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "found": { + "const": true + } + } + }, + "then": { + "required": [ + "customerId" + ] + } + } + ] + }, + "documentos": { + "type": "object", + "properties": { + "ine": { + "type": "string", + "format": "uuid" + }, + "cuenta": { + "type": "string", + "format": "uuid" + } + }, + "required": [], + "additionalProperties": false + }, + "validaciones": { + "type": "object", + "properties": { + "ine": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "cuenta": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "buro": { + "type": "object", + "properties": { + "score": { + "type": "number" + }, + "consultationId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "score", + "consultationId" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "client", + "consentimientoId" + ], + "additionalProperties": false + }, + "states": [ + { + "key": "CREADA", + "terminal": false + }, + { + "key": "INE_PENDIENTE", + "terminal": false + }, + { + "key": "ESTADO_CUENTA_PENDIENTE", + "terminal": false + }, + { + "key": "BURO_PENDIENTE", + "terminal": false + }, + { + "key": "PASO_N", + "terminal": true + }, + { + "key": "PASO_M", + "terminal": true + }, + { + "key": "REVISION_MANUAL", + "terminal": true + } + ], + "events": [ + { + "key": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "key": "REGISTRAR_INE", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + } + }, + { + "key": "REGISTRAR_ESTADO_CUENTA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + } + }, + { + "key": "VALIDAR_BURO", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + } + ], + "transitions": [ + { + "id": "asignar-cliente", + "from": "CREADA", + "event": "ASIGNAR_CLIENTE", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "cliente", + "type": "INVOKE_INTEGRATION", + "integrationKey": "CLIENTES_CONSULTA", + "contractVersion": "1", + "inputs": { + "client": { + "path": "/request/data/client" + } + }, + "resultKey": "cliente" + } + ], + "decision": "CLIENTE_EXISTE", + "destinations": { + "CLIENTE_ENCONTRADO": "INE_PENDIENTE", + "CLIENTE_NO_ENCONTRADO": "CREADA" + }, + "updates": [ + { + "target": "/cliente", + "value": { + "path": "/results/cliente" + } + } + ] + }, + { + "id": "documento-ine", + "from": "INE_PENDIENTE", + "event": "REGISTRAR_INE", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "registro", + "type": "REGISTER_DOCUMENT", + "documentType": "INE", + "inputs": { + "documentId": { + "path": "/event/data/documentId" + } + }, + "resultKey": "registro" + }, + { + "id": "validacion", + "type": "INVOKE_INTEGRATION", + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "INE", + "profileVersion": "1", + "inputs": { + "documentId": { + "path": "/results/registro/documentId" + } + }, + "resultKey": "validacion" + } + ], + "decision": "VALIDAR_INE", + "destinations": { + "DOCUMENTO_VALIDO": "ESTADO_CUENTA_PENDIENTE", + "DOCUMENTO_INVALIDO": "INE_PENDIENTE" + }, + "updates": [ + { + "target": "/documentos/ine", + "value": { + "path": "/results/registro/documentId" + } + }, + { + "target": "/validaciones/ine", + "value": { + "path": "/results/validacion" + } + } + ] + }, + { + "id": "documento-cuenta", + "from": "ESTADO_CUENTA_PENDIENTE", + "event": "REGISTRAR_ESTADO_CUENTA", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "registro", + "type": "REGISTER_DOCUMENT", + "documentType": "ESTADO_CUENTA", + "inputs": { + "documentId": { + "path": "/event/data/documentId" + } + }, + "resultKey": "registro" + }, + { + "id": "validacion", + "type": "INVOKE_INTEGRATION", + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "ESTADO_CUENTA", + "profileVersion": "1", + "inputs": { + "documentId": { + "path": "/results/registro/documentId" + } + }, + "resultKey": "validacion" + } + ], + "decision": "VALIDAR_ESTADO_CUENTA", + "destinations": { + "DOCUMENTO_VALIDO": "BURO_PENDIENTE", + "DOCUMENTO_INVALIDO": "ESTADO_CUENTA_PENDIENTE" + }, + "updates": [ + { + "target": "/documentos/cuenta", + "value": { + "path": "/results/registro/documentId" + } + }, + { + "target": "/validaciones/cuenta", + "value": { + "path": "/results/validacion" + } + } + ] + }, + { + "id": "consultar-buro", + "from": "BURO_PENDIENTE", + "event": "VALIDAR_BURO", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "buro", + "type": "INVOKE_INTEGRATION", + "integrationKey": "BURO_CONSULTA", + "contractVersion": "1", + "inputs": { + "client": { + "path": "/request/data/cliente/customerId" + }, + "consentimientoId": { + "path": "/request/data/consentimientoId" + } + }, + "resultKey": "buro" + } + ], + "decision": "RUTA_SCORING", + "destinations": { + "N": "PASO_N", + "M": "PASO_M", + "MANUAL": "REVISION_MANUAL" + }, + "updates": [ + { + "target": "/validaciones/buro", + "value": { + "path": "/results/buro" + } + } + ] + } + ], + "decisions": [ + { + "id": "CLIENTE_EXISTE", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "encontrado", + "when": { + "op": "EQ", + "left": { + "path": "/results/cliente/found" + }, + "right": { + "literal": true + } + }, + "output": "CLIENTE_ENCONTRADO" + } + ], + "defaultOutput": "CLIENTE_NO_ENCONTRADO" + }, + { + "id": "VALIDAR_INE", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "valido", + "when": { + "op": "EQ", + "left": { + "path": "/results/validacion/status" + }, + "right": { + "literal": "VALID" + } + }, + "output": "DOCUMENTO_VALIDO" + } + ], + "defaultOutput": "DOCUMENTO_INVALIDO" + }, + { + "id": "VALIDAR_ESTADO_CUENTA", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "valido", + "when": { + "op": "EQ", + "left": { + "path": "/results/validacion/status" + }, + "right": { + "literal": "VALID" + } + }, + "output": "DOCUMENTO_VALIDO" + } + ], + "defaultOutput": "DOCUMENTO_INVALIDO" + }, + { + "id": "RUTA_SCORING", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "alto", + "when": { + "op": "GT", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 70 + } + }, + "output": "N" + }, + { + "id": "medio", + "when": { + "op": "AND", + "args": [ + { + "op": "GT", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 60 + } + }, + { + "op": "LTE", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 70 + } + } + ] + }, + "output": "M" + } + ], + "defaultOutput": "MANUAL" + } + ], + "tasks": [ + { + "state": "CREADA", + "event": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Consultar cliente", + "fieldOrder": [] + } + }, + { + "state": "INE_PENDIENTE", + "event": "REGISTRAR_INE", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "ui": { + "component": "DOCUMENT_UPLOAD", + "title": "Identificación INE", + "documentType": "INE", + "acceptedMimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png" + ], + "maxBytes": 5242880 + } + }, + { + "state": "ESTADO_CUENTA_PENDIENTE", + "event": "REGISTRAR_ESTADO_CUENTA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "ui": { + "component": "DOCUMENT_UPLOAD", + "title": "Estado de cuenta", + "documentType": "ESTADO_CUENTA", + "acceptedMimeTypes": [ + "application/pdf" + ], + "maxBytes": 5242880 + } + }, + { + "state": "BURO_PENDIENTE", + "event": "VALIDAR_BURO", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Consultar buró con consentimiento registrado", + "fieldOrder": [] + } + } + ] +} diff --git a/examples/flows/credito-v2-licencia.json b/examples/flows/credito-v2-licencia.json new file mode 100644 index 0000000..47195b6 --- /dev/null +++ b/examples/flows/credito-v2-licencia.json @@ -0,0 +1,772 @@ +{ + "schemaVersion": 1, + "flowKey": "CREDITO", + "version": 2, + "initialState": "CREADA", + "initialDataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "consentimientoId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "client", + "consentimientoId" + ], + "additionalProperties": false + }, + "dataSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "consentimientoId": { + "type": "string", + "minLength": 1 + }, + "cliente": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "customerId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "found" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "found": { + "const": true + } + } + }, + "then": { + "required": [ + "customerId" + ] + } + } + ] + }, + "documentos": { + "type": "object", + "properties": { + "ine": { + "type": "string", + "format": "uuid" + }, + "cuenta": { + "type": "string", + "format": "uuid" + }, + "licencia": { + "type": "string", + "format": "uuid" + } + }, + "required": [], + "additionalProperties": false + }, + "validaciones": { + "type": "object", + "properties": { + "ine": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "cuenta": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "licencia": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "buro": { + "type": "object", + "properties": { + "score": { + "type": "number" + }, + "consultationId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "score", + "consultationId" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [ + "client", + "consentimientoId" + ], + "additionalProperties": false + }, + "states": [ + { + "key": "CREADA", + "terminal": false + }, + { + "key": "INE_PENDIENTE", + "terminal": false + }, + { + "key": "ESTADO_CUENTA_PENDIENTE", + "terminal": false + }, + { + "key": "LICENCIA_PENDIENTE", + "terminal": false + }, + { + "key": "BURO_PENDIENTE", + "terminal": false + }, + { + "key": "PASO_N", + "terminal": true + }, + { + "key": "PASO_M", + "terminal": true + }, + { + "key": "REVISION_MANUAL", + "terminal": true + } + ], + "events": [ + { + "key": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "key": "REGISTRAR_INE", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + } + }, + { + "key": "REGISTRAR_ESTADO_CUENTA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + } + }, + { + "key": "REGISTRAR_LICENCIA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + } + }, + { + "key": "VALIDAR_BURO", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + } + ], + "transitions": [ + { + "id": "asignar-cliente", + "from": "CREADA", + "event": "ASIGNAR_CLIENTE", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "cliente", + "type": "INVOKE_INTEGRATION", + "integrationKey": "CLIENTES_CONSULTA", + "contractVersion": "1", + "inputs": { + "client": { + "path": "/request/data/client" + } + }, + "resultKey": "cliente" + } + ], + "decision": "CLIENTE_EXISTE", + "destinations": { + "CLIENTE_ENCONTRADO": "INE_PENDIENTE", + "CLIENTE_NO_ENCONTRADO": "CREADA" + }, + "updates": [ + { + "target": "/cliente", + "value": { + "path": "/results/cliente" + } + } + ] + }, + { + "id": "documento-ine", + "from": "INE_PENDIENTE", + "event": "REGISTRAR_INE", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "registro", + "type": "REGISTER_DOCUMENT", + "documentType": "INE", + "inputs": { + "documentId": { + "path": "/event/data/documentId" + } + }, + "resultKey": "registro" + }, + { + "id": "validacion", + "type": "INVOKE_INTEGRATION", + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "INE", + "profileVersion": "1", + "inputs": { + "documentId": { + "path": "/results/registro/documentId" + } + }, + "resultKey": "validacion" + } + ], + "decision": "VALIDAR_INE", + "destinations": { + "DOCUMENTO_VALIDO": "ESTADO_CUENTA_PENDIENTE", + "DOCUMENTO_INVALIDO": "INE_PENDIENTE" + }, + "updates": [ + { + "target": "/documentos/ine", + "value": { + "path": "/results/registro/documentId" + } + }, + { + "target": "/validaciones/ine", + "value": { + "path": "/results/validacion" + } + } + ] + }, + { + "id": "documento-cuenta", + "from": "ESTADO_CUENTA_PENDIENTE", + "event": "REGISTRAR_ESTADO_CUENTA", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "registro", + "type": "REGISTER_DOCUMENT", + "documentType": "ESTADO_CUENTA", + "inputs": { + "documentId": { + "path": "/event/data/documentId" + } + }, + "resultKey": "registro" + }, + { + "id": "validacion", + "type": "INVOKE_INTEGRATION", + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "ESTADO_CUENTA", + "profileVersion": "1", + "inputs": { + "documentId": { + "path": "/results/registro/documentId" + } + }, + "resultKey": "validacion" + } + ], + "decision": "VALIDAR_ESTADO_CUENTA", + "destinations": { + "DOCUMENTO_VALIDO": "LICENCIA_PENDIENTE", + "DOCUMENTO_INVALIDO": "ESTADO_CUENTA_PENDIENTE" + }, + "updates": [ + { + "target": "/documentos/cuenta", + "value": { + "path": "/results/registro/documentId" + } + }, + { + "target": "/validaciones/cuenta", + "value": { + "path": "/results/validacion" + } + } + ] + }, + { + "id": "documento-licencia", + "from": "LICENCIA_PENDIENTE", + "event": "REGISTRAR_LICENCIA", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "registro", + "type": "REGISTER_DOCUMENT", + "documentType": "LICENCIA_CONDUCIR", + "inputs": { + "documentId": { + "path": "/event/data/documentId" + } + }, + "resultKey": "registro" + }, + { + "id": "validacion", + "type": "INVOKE_INTEGRATION", + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "LICENCIA_CONDUCIR", + "profileVersion": "1", + "inputs": { + "documentId": { + "path": "/results/registro/documentId" + } + }, + "resultKey": "validacion" + } + ], + "decision": "VALIDAR_LICENCIA_CONDUCIR", + "destinations": { + "DOCUMENTO_VALIDO": "BURO_PENDIENTE", + "DOCUMENTO_INVALIDO": "LICENCIA_PENDIENTE" + }, + "updates": [ + { + "target": "/documentos/licencia", + "value": { + "path": "/results/registro/documentId" + } + }, + { + "target": "/validaciones/licencia", + "value": { + "path": "/results/validacion" + } + } + ] + }, + { + "id": "consultar-buro", + "from": "BURO_PENDIENTE", + "event": "VALIDAR_BURO", + "allowedRoles": [ + "ANALISTA" + ], + "actions": [ + { + "id": "buro", + "type": "INVOKE_INTEGRATION", + "integrationKey": "BURO_CONSULTA", + "contractVersion": "1", + "inputs": { + "client": { + "path": "/request/data/cliente/customerId" + }, + "consentimientoId": { + "path": "/request/data/consentimientoId" + } + }, + "resultKey": "buro" + } + ], + "decision": "RUTA_SCORING", + "destinations": { + "N": "PASO_N", + "M": "PASO_M", + "MANUAL": "REVISION_MANUAL" + }, + "updates": [ + { + "target": "/validaciones/buro", + "value": { + "path": "/results/buro" + } + } + ] + } + ], + "decisions": [ + { + "id": "CLIENTE_EXISTE", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "encontrado", + "when": { + "op": "EQ", + "left": { + "path": "/results/cliente/found" + }, + "right": { + "literal": true + } + }, + "output": "CLIENTE_ENCONTRADO" + } + ], + "defaultOutput": "CLIENTE_NO_ENCONTRADO" + }, + { + "id": "VALIDAR_INE", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "valido", + "when": { + "op": "EQ", + "left": { + "path": "/results/validacion/status" + }, + "right": { + "literal": "VALID" + } + }, + "output": "DOCUMENTO_VALIDO" + } + ], + "defaultOutput": "DOCUMENTO_INVALIDO" + }, + { + "id": "VALIDAR_ESTADO_CUENTA", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "valido", + "when": { + "op": "EQ", + "left": { + "path": "/results/validacion/status" + }, + "right": { + "literal": "VALID" + } + }, + "output": "DOCUMENTO_VALIDO" + } + ], + "defaultOutput": "DOCUMENTO_INVALIDO" + }, + { + "id": "VALIDAR_LICENCIA_CONDUCIR", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "valido", + "when": { + "op": "EQ", + "left": { + "path": "/results/validacion/status" + }, + "right": { + "literal": "VALID" + } + }, + "output": "DOCUMENTO_VALIDO" + } + ], + "defaultOutput": "DOCUMENTO_INVALIDO" + }, + { + "id": "RUTA_SCORING", + "hitPolicy": "UNIQUE", + "rules": [ + { + "id": "alto", + "when": { + "op": "GT", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 70 + } + }, + "output": "N" + }, + { + "id": "medio", + "when": { + "op": "AND", + "args": [ + { + "op": "GT", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 60 + } + }, + { + "op": "LTE", + "left": { + "path": "/results/buro/score" + }, + "right": { + "literal": 70 + } + } + ] + }, + "output": "M" + } + ], + "defaultOutput": "MANUAL" + } + ], + "tasks": [ + { + "state": "CREADA", + "event": "ASIGNAR_CLIENTE", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Consultar cliente", + "fieldOrder": [] + } + }, + { + "state": "INE_PENDIENTE", + "event": "REGISTRAR_INE", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "ui": { + "component": "DOCUMENT_UPLOAD", + "title": "Identificación INE", + "documentType": "INE", + "acceptedMimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png" + ], + "maxBytes": 5242880 + } + }, + { + "state": "ESTADO_CUENTA_PENDIENTE", + "event": "REGISTRAR_ESTADO_CUENTA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "ui": { + "component": "DOCUMENT_UPLOAD", + "title": "Estado de cuenta", + "documentType": "ESTADO_CUENTA", + "acceptedMimeTypes": [ + "application/pdf" + ], + "maxBytes": 5242880 + } + }, + { + "state": "LICENCIA_PENDIENTE", + "event": "REGISTRAR_LICENCIA", + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "ui": { + "component": "DOCUMENT_UPLOAD", + "title": "Licencia de conducir", + "documentType": "LICENCIA_CONDUCIR", + "acceptedMimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png" + ], + "maxBytes": 5242880 + } + }, + { + "state": "BURO_PENDIENTE", + "event": "VALIDAR_BURO", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "ui": { + "component": "FORM", + "title": "Consultar buró con consentimiento registrado", + "fieldOrder": [] + } + } + ] +} diff --git a/examples/flows/seed-request-types.sql b/examples/flows/seed-request-types.sql new file mode 100644 index 0000000..03287a6 --- /dev/null +++ b/examples/flows/seed-request-types.sql @@ -0,0 +1,4 @@ +-- Solo entorno de demo, como dueño del esquema; ajustar orchestrator al esquema configurado. +INSERT INTO orchestrator.request_type(type_key) +VALUES ('CREDITO'), ('COMPRA') +ON CONFLICT (type_key) DO NOTHING; diff --git a/examples/http/available-events.http b/examples/http/available-events.http new file mode 100644 index 0000000..e2a1efb --- /dev/null +++ b/examples/http/available-events.http @@ -0,0 +1,27 @@ +@baseUrl = http://localhost:8787 +@requestId = 00000000-0000-0000-0000-000000000001 +@jwt = REPLACE_WITH_OWNER_JWT +@revision = 0 +@event = SUBMIT +@documentId = 00000000-0000-0000-0000-000000000002 + +### Requiere REQUEST_API_ENABLED=true. Usa la versión fijada a esta solicitud. +GET {{baseUrl}}/api/v1/requests/{{requestId}}/available-events +Authorization: Bearer {{jwt}} + +### Solo si no hay blockedByExecutionId. Sustituir evento/datos según inputSchema. +### documentId debe existir y pertenecer a esta solicitud en el servicio documental. +POST {{baseUrl}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{jwt}} +Idempotency-Key: document-submission-demo-1 +If-Match: "{{revision}}" +Content-Type: application/json + +{ + "event": "{{event}}", + "data": {"documentId": "{{documentId}}"} +} + +### Tras completar la ejecución, consultar datos/razones de negocio y las tareas otra vez. +GET {{baseUrl}}/api/v1/requests/{{requestId}} +Authorization: Bearer {{jwt}} diff --git a/examples/http/demo-flows.http b/examples/http/demo-flows.http new file mode 100644 index 0000000..4839df7 --- /dev/null +++ b/examples/http/demo-flows.http @@ -0,0 +1,176 @@ +# Entorno de demo exclusivamente. Sustituir tokens e IDs con respuestas reales. +@base = http://localhost:8080 +@editor = REEMPLAZAR +@publisher = REEMPLAZAR +@manager = REEMPLAZAR +@analista = REEMPLAZAR +@creditoV1 = REEMPLAZAR_UUID +@creditoV2 = REEMPLAZAR_UUID +@compraV1 = REEMPLAZAR_UUID +@requestId = REEMPLAZAR_UUID +@executionId = REEMPLAZAR_UUID +@documentId = REEMPLAZAR_UUID +@revision = 0 +@typeRevision = 0 +@eventKey = REEMPLAZAR_CLAVE_UNICA + +### Crear credito-v1; copiar id a creditoV1 +POST {{base}}/api/v1/admin/flows +Authorization: Bearer {{editor}} +Content-Type: application/json + +< ../flows/credito-v1.json + +### Validar credito-v1 +POST {{base}}/api/v1/admin/flows/{{creditoV1}}/validate +Authorization: Bearer {{editor}} + +### Publicar credito-v1; revisión inicial 0, publicado 1 +POST {{base}}/api/v1/admin/flows/{{creditoV1}}/publish +Authorization: Bearer {{publisher}} +If-Match: "0" + +### Crear credito-v2-licencia; copiar id a creditoV2 +POST {{base}}/api/v1/admin/flows +Authorization: Bearer {{editor}} +Content-Type: application/json + +< ../flows/credito-v2-licencia.json + +### Validar credito-v2-licencia +POST {{base}}/api/v1/admin/flows/{{creditoV2}}/validate +Authorization: Bearer {{editor}} + +### Publicar credito-v2-licencia; revisión inicial 0, publicado 1 +POST {{base}}/api/v1/admin/flows/{{creditoV2}}/publish +Authorization: Bearer {{publisher}} +If-Match: "0" + +### Crear compra-v1; copiar id a compraV1 +POST {{base}}/api/v1/admin/flows +Authorization: Bearer {{editor}} +Content-Type: application/json + +< ../flows/compra-v1.json + +### Validar compra-v1 +POST {{base}}/api/v1/admin/flows/{{compraV1}}/validate +Authorization: Bearer {{editor}} + +### Publicar compra-v1; revisión inicial 0, publicado 1 +POST {{base}}/api/v1/admin/flows/{{compraV1}}/publish +Authorization: Bearer {{publisher}} +If-Match: "0" + +### Activar creditoV1. Para v2: crear primero una solicitud v1; usar revisión actual del tipo. +PUT {{base}}/api/v1/admin/request-types/CREDITO/active-flow +Authorization: Bearer {{manager}} +Content-Type: application/json +If-Match: "{{typeRevision}}" + +{"flowId":"{{creditoV1}}"} + +### Activar compraV1. Para v2: crear primero una solicitud v1; usar revisión actual del tipo. +PUT {{base}}/api/v1/admin/request-types/COMPRA/active-flow +Authorization: Bearer {{manager}} +Content-Type: application/json +If-Match: "{{typeRevision}}" + +{"flowId":"{{compraV1}}"} + +### Activar creditoV2. Para v2: crear primero una solicitud v1; usar revisión actual del tipo. +PUT {{base}}/api/v1/admin/request-types/CREDITO/active-flow +Authorization: Bearer {{manager}} +Content-Type: application/json +If-Match: "{{typeRevision}}" + +{"flowId":"{{creditoV2}}"} + +### Crear crédito (ejecutar antes y después de activar v2, con claves distintas) +POST {{base}}/api/v1/requests +Authorization: Bearer {{analista}} +Content-Type: application/json +Idempotency-Key: {{eventKey}} + +{"typeKey":"CREDITO","data":{"client":"synthetic-client","consentimientoId":"synthetic-consent"}} + +### Crear compra +POST {{base}}/api/v1/requests +Authorization: Bearer {{analista}} +Content-Type: application/json +Idempotency-Key: {{eventKey}} + +{"typeKey":"COMPRA","data":{"client":"synthetic-client","orderId":"synthetic-order","amount":100}} + +### Consultar solicitud: copiar revisión a revision, comprobar flow.id y currentState +GET {{base}}/api/v1/requests/{{requestId}} +Authorization: Bearer {{analista}} + +### Consultar tarea disponible y restricciones documentales +GET {{base}}/api/v1/requests/{{requestId}}/available-events +Authorization: Bearer {{analista}} + +### Consultar ejecución hasta SUCCEEDED; FAILED/RETRY_WAIT requieren atender causa +GET {{base}}/api/v1/executions/{{executionId}} +Authorization: Bearer {{analista}} + +### ASIGNAR_CLIENTE: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"ASIGNAR_CLIENTE","data":{}} + +### REGISTRAR_INE: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"REGISTRAR_INE","data":{"documentId":"{{documentId}}"}} + +### REGISTRAR_ESTADO_CUENTA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"REGISTRAR_ESTADO_CUENTA","data":{"documentId":"{{documentId}}"}} + +### REGISTRAR_LICENCIA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"REGISTRAR_LICENCIA","data":{"documentId":"{{documentId}}"}} + +### VALIDAR_BURO: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"VALIDAR_BURO","data":{}} + +### CONFIRMAR_COMPRA: revisar tarea, actualizar revision y eventKey; licencia solo v2, confirmar solo compra +POST {{base}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{analista}} +Content-Type: application/json +If-Match: "{{revision}}" +Idempotency-Key: {{eventKey}} + +{"event":"CONFIRMAR_COMPRA","data":{"confirmada":true}} + +# Probar confirmada:false en otra compra para COMPRA_CANCELADA. +# Configurar proveedor mock con score 71/70/61/60 para N/M/M/REVISION_MANUAL. +# INVALID + reasonCodes:["EXPIRED"] conserva LICENCIA_PENDIENTE; nuevo evento VALID avanza. +# Cada ensayo de score usa otra solicitud y documentos propios. No enviar score desde el cliente. +# DemoFlowsIT automatiza estas variantes sin editar rutas ni contactar proveedores reales. + diff --git a/examples/http/events.http b/examples/http/events.http new file mode 100644 index 0000000..982864d --- /dev/null +++ b/examples/http/events.http @@ -0,0 +1,18 @@ +### Obtener revision desde GET /requests/{id}; conservarla al reintentar este comando. +### Variables: baseUrl, token, requestId, eventKey, requestRevision. +POST {{baseUrl}}/api/v1/requests/{{requestId}}/events +Authorization: Bearer {{token}} +Idempotency-Key: demo-event-001 +If-Match: "{{requestRevision}}" +Content-Type: application/json + +{"event":"{{eventKey}}","data":{}} + +### Ajustar data al inputSchema del evento. 202 confirma aceptación, no ejecución. +### T10 procesa cuando WORKER_ENABLED=true; configurar adapters T11 antes de flujos externos. +GET {{baseUrl}}/api/v1/requests/{{requestId}} +Authorization: Bearer {{token}} + +### T10: consultar ejecucion aceptada (requiere REQUEST_API_ENABLED=true). +GET {{baseUrl}}/api/v1/executions/{{executionId}} +Authorization: Bearer {{token}} diff --git a/examples/http/history-operations.http b/examples/http/history-operations.http new file mode 100644 index 0000000..46acea4 --- /dev/null +++ b/examples/http/history-operations.http @@ -0,0 +1,48 @@ +@base = http://localhost:8080 +@analista = REEMPLAZAR_TOKEN_PROPIETARIO +@operator = REEMPLAZAR_TOKEN_EXECUTION_OPERATOR +@requestId = REEMPLAZAR_UUID +@executionId = REEMPLAZAR_UUID +@cursor = REEMPLAZAR_NEXT_CURSOR + +### Página inicial: copiar nextCursor sin modificarlo +GET {{base}}/api/v1/requests/{{requestId}}/history?limit=50 +Authorization: Bearer {{analista}} + +### Página siguiente, con los mismos filtros +GET {{base}}/api/v1/requests/{{requestId}}/history?limit=50&cursor={{cursor}} +Authorization: Bearer {{analista}} + +### Filtros; from inclusivo, to exclusivo. Cursor nuevo para estos filtros. +GET {{base}}/api/v1/requests/{{requestId}}/history?kind=TRANSITION_COMPLETED&executionId={{executionId}}&from=2026-01-01T00:00:00Z&to=2027-01-01T00:00:00Z +Authorization: Bearer {{analista}} + +### Salud pública, sin detalles +GET {{base}}/actuator/health/liveness + +### Readiness incluye PostgreSQL +GET {{base}}/actuator/health/readiness + +### Requiere rol operador y ámbito incluido en ADMIN_BUSINESS_SCOPES +GET {{base}}/actuator/metrics +Authorization: Bearer {{operator}} + +### Estados técnicos (no sumar réplicas que consultan la misma DB) +GET {{base}}/actuator/metrics/orchestrator.executions?tag=state:PENDING +Authorization: Bearer {{operator}} + +### Antigüedad del pendiente más viejo, segundos +GET {{base}}/actuator/metrics/orchestrator.pending.oldest.seconds +Authorization: Bearer {{operator}} + +### Estado de la muestra SQL; 0 indica que los gauges de cola son desconocidos +GET {{base}}/actuator/metrics/orchestrator.queue.sample.available +Authorization: Bearer {{operator}} + +### Comandos HTTP (incluye replays); contador local al proceso +GET {{base}}/actuator/metrics/orchestrator.commands?tag=command:event&tag=result:accepted +Authorization: Bearer {{operator}} + +### Timer disponible tras invocar una integración; solo claves configuradas +GET {{base}}/actuator/metrics/orchestrator.integration.duration?tag=integration:BURO_CONSULTA +Authorization: Bearer {{operator}} diff --git a/examples/http/reconciliation.http b/examples/http/reconciliation.http new file mode 100644 index 0000000..e257980 --- /dev/null +++ b/examples/http/reconciliation.http @@ -0,0 +1,39 @@ +@baseUrl = http://localhost:8787 +@executionId = 00000000-0000-0000-0000-000000000001 +@operatorJwt = REPLACE_WITH_EXECUTION_OPERATOR_JWT + +### Solo después de verificar el efecto y el resultado con el proveedor. +POST {{baseUrl}}/api/v1/admin/executions/{{executionId}}/resolve +Authorization: Bearer {{operatorJwt}} +Idempotency-Key: case-demo-resume-1 +Content-Type: application/json + +{ + "mode": "RESUME", + "evidence": { + "reference": "CASE-DEMO-001", + "reason": "Ejemplo sintético: sustituir por evidencia verificada del caso real." + }, + "verifiedActions": [ + { + "actionId": "lookup", + "result": {"score": 70}, + "providerReference": "PROVIDER-VERIFIED-REFERENCE" + } + ] +} + +### Alternativa excluyente: cerrar únicamente tras verificar ausencia/compensación. +POST {{baseUrl}}/api/v1/admin/executions/{{executionId}}/resolve +Authorization: Bearer {{operatorJwt}} +Idempotency-Key: case-demo-close-1 +Content-Type: application/json + +{ + "mode": "CLOSE_WITHOUT_TRANSITION", + "evidence": { + "reference": "CASE-DEMO-002", + "reason": "Ejemplo sintético: sustituir por evidencia de compensación verificada." + }, + "effectStatus": "COMPENSATED_VERIFIED" +} diff --git a/examples/http/requests.http b/examples/http/requests.http new file mode 100644 index 0000000..173a864 --- /dev/null +++ b/examples/http/requests.http @@ -0,0 +1,13 @@ +### Requiere tipo ya activado, REQUEST_API_ENABLED=true y JWT válido con business_scope. +### Variables del cliente HTTP: baseUrl, token, typeKey, requestId. +POST {{baseUrl}}/api/v1/requests +Authorization: Bearer {{token}} +Idempotency-Key: demo-create-001 +Content-Type: application/json + +{"typeKey":"{{typeKey}}","data":{}} + +### Repetir POST con la misma clave/cuerpo retorna la respuesta original. +### Ajustar data al initialDataSchema/dataSchema del flujo publicado. +GET {{baseUrl}}/api/v1/requests/{{requestId}} +Authorization: Bearer {{token}} diff --git a/examples/http/simulate.http b/examples/http/simulate.http new file mode 100644 index 0000000..20ea491 --- /dev/null +++ b/examples/http/simulate.http @@ -0,0 +1,24 @@ +@base = http://localhost:8080 +@editor = REEMPLAZAR_TOKEN_FLOW_EDITOR +@flowId = REEMPLAZAR_UUID_CREDITO_V1 + +### Simulación pura de una transición, usando fixtures sintéticos +POST {{base}}/api/v1/admin/flows/{{flowId}}/simulate +Authorization: Bearer {{editor}} +Content-Type: application/json + +{ + "state": "BURO_PENDIENTE", + "requestData": { + "client": "synthetic-client", + "consentimientoId": "synthetic-consent", + "cliente": {"found": true, "customerId": "synthetic-customer"} + }, + "event": {"event": "VALIDAR_BURO", "data": {}}, + "actor": {"subject": "synthetic-analyst", "businessScope": "demo", "roles": ["ANALISTA"]}, + "results": {"buro": {"score": 71, "consultationId": "synthetic-consultation"}} +} + +# Cambiar score a 70, 61 y 60: PASO_M, PASO_M y REVISION_MANUAL. +# score string o ausente es un error de contrato, sin ruta por defecto. +# El actor de fixture no sustituye el JWT del editor. No invoca buró real. diff --git a/examples/integrations/integration-routes.example.json b/examples/integrations/integration-routes.example.json new file mode 100644 index 0000000..1d555d7 --- /dev/null +++ b/examples/integrations/integration-routes.example.json @@ -0,0 +1,82 @@ +{ + "routes": [ + { + "integrationKey": "CLIENTES_CONSULTA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "https://provider.example.invalid/clientes_consulta", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "LICENCIA_CONDUCIR", + "profileVersion": "1", + "url": "https://provider.example.invalid/documento_valida", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "BURO_CONSULTA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "https://provider.example.invalid/buro_consulta", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTS_METADATA", + "contractVersion": "1", + "profile": "", + "profileVersion": "", + "url": "https://provider.example.invalid/documents_metadata", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "INE", + "profileVersion": "1", + "url": "https://provider.example.invalid/documento_valida", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + }, + { + "integrationKey": "DOCUMENTO_VALIDA", + "contractVersion": "1", + "profile": "ESTADO_CUENTA", + "profileVersion": "1", + "url": "https://provider.example.invalid/documento_valida", + "tokenEnvironment": "PROVIDER_TOKEN", + "connectMs": 1000, + "timeoutMs": 5000, + "maxBytes": 262144, + "outputMappings": {}, + "notFoundOutput": null + } + ] +} diff --git a/examples/integrations/publication-catalog.json b/examples/integrations/publication-catalog.json new file mode 100644 index 0000000..2f4c590 --- /dev/null +++ b/examples/integrations/publication-catalog.json @@ -0,0 +1,361 @@ +{ + "roles": [ + "ANALISTA" + ], + "handlers": { + "INVOKE_INTEGRATION": { + "effect": "READ_ONLY", + "configurationSchema": {}, + "inputSchema": {}, + "outputSchema": {} + }, + "REGISTER_DOCUMENT": { + "effect": "READ_ONLY", + "configurationSchema": {}, + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + }, + "requestId": { + "type": "string", + "format": "uuid" + }, + "businessScope": { + "type": "string", + "minLength": 1 + }, + "documentType": { + "type": "string", + "minLength": 1 + }, + "storageDocumentId": { + "type": "string", + "minLength": 1 + }, + "mimeType": { + "type": "string", + "minLength": 1 + }, + "sizeBytes": { + "type": "integer", + "minimum": 0 + }, + "checksum": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + } + }, + "required": [ + "documentId", + "requestId", + "businessScope", + "documentType", + "storageDocumentId", + "mimeType", + "sizeBytes", + "checksum" + ], + "additionalProperties": false + } + } + }, + "integrations": { + "CLIENTES_CONSULTA": { + "defaultVersion": "1", + "contracts": { + "1": { + "inputSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "client" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "customerId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "found" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "found": { + "const": true + } + } + }, + "then": { + "required": [ + "customerId" + ] + } + } + ] + }, + "effect": "READ_ONLY", + "profiles": {}, + "defaultProfiles": {} + } + } + }, + "DOCUMENTO_VALIDA": { + "defaultVersion": "1", + "contracts": { + "1": { + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "documentId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "VALID", + "INVALID" + ] + }, + "reasonCodes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": [ + "status", + "reasonCodes" + ], + "additionalProperties": false + }, + "effect": "READ_ONLY", + "profiles": { + "LICENCIA_CONDUCIR": [ + "1" + ], + "INE": [ + "1" + ], + "ESTADO_CUENTA": [ + "1" + ] + }, + "defaultProfiles": { + "LICENCIA_CONDUCIR": "1", + "INE": "1", + "ESTADO_CUENTA": "1" + } + } + } + }, + "BURO_CONSULTA": { + "defaultVersion": "1", + "contracts": { + "1": { + "inputSchema": { + "type": "object", + "properties": { + "client": { + "type": "string", + "minLength": 1 + }, + "consentimientoId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "client", + "consentimientoId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "score": { + "type": "number" + }, + "consultationId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "score", + "consultationId" + ], + "additionalProperties": false + }, + "effect": "READ_ONLY", + "profiles": {}, + "defaultProfiles": {} + } + } + }, + "DOCUMENTS_METADATA": { + "defaultVersion": "1", + "contracts": { + "1": { + "inputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + }, + "requestId": { + "type": "string", + "format": "uuid" + }, + "businessScope": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "documentId", + "requestId", + "businessScope" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "documentId": { + "type": "string", + "format": "uuid" + }, + "requestId": { + "type": "string", + "format": "uuid" + }, + "businessScope": { + "type": "string", + "minLength": 1 + }, + "documentType": { + "type": "string", + "minLength": 1 + }, + "storageDocumentId": { + "type": "string", + "minLength": 1 + }, + "mimeType": { + "type": "string", + "minLength": 1 + }, + "sizeBytes": { + "type": "integer", + "minimum": 0 + }, + "checksum": { + "type": "string", + "pattern": "^[a-fA-F0-9]{64}$" + }, + "scanStatus": { + "type": "string", + "enum": [ + "CLEAN", + "PENDING", + "INFECTED" + ] + } + }, + "required": [ + "documentId", + "requestId", + "businessScope", + "documentType", + "storageDocumentId", + "mimeType", + "sizeBytes", + "checksum", + "scanStatus" + ], + "additionalProperties": false + }, + "effect": "READ_ONLY", + "profiles": {}, + "defaultProfiles": {} + } + } + } + }, + "documents": { + "DOCUMENT": { + "mimeTypes": [ + "application/pdf" + ], + "maxBytes": 1000 + }, + "LICENCIA_CONDUCIR": { + "mimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png" + ], + "maxBytes": 5242880 + }, + "INE": { + "mimeTypes": [ + "application/pdf", + "image/jpeg", + "image/png" + ], + "maxBytes": 5242880 + }, + "ESTADO_CUENTA": { + "mimeTypes": [ + "application/pdf" + ], + "maxBytes": 5242880 + } + } +} diff --git a/pom.xml b/pom.xml index 7f9da03..63f2eac 100644 --- a/pom.xml +++ b/pom.xml @@ -30,10 +30,15 @@ org.flywaydbflyway-database-postgresql org.postgresqlpostgresqlruntime org.junit.jupiterjunit-jupitertest - com.networkntjson-schema-validator1.5.9test + com.networkntjson-schema-validator1.5.9 io.swagger.parser.v3swagger-parser-v32.1.35test + org.testcontainerstestcontainers-postgresqltest + + src/main/resources + schemasschemas*.schema.json + org.springframework.bootspring-boot-maven-plugin @@ -47,14 +52,25 @@ org.apache.maven.pluginsmaven-surefire-plugin3.5.4 + true org.apache.maven.pluginsmaven-failsafe-plugin3.5.4 + true integration-testverify + + migration-tests-external + + org.apache.maven.pluginsmaven-failsafe-plugin + + true + + + postgres96-check diff --git a/schemas/README.md b/schemas/README.md index 005e6de..e60e485 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -25,7 +25,7 @@ Condiciones: - AND/OR: op y args con al menos dos condiciones ordenadas. - NOT: op y arg con una condición. -Tipos, BigDecimal, cortocircuito, null frente a ausencia, IN homogéneo, solapamientos y UNIQUE ambiguo se evaluarán en T05. UNIQUE requiere defaultOutput; FIRST sólo se habilita seleccionándolo explícitamente como hitPolicy. +T05 implementa tipos, BigDecimal, cortocircuito, null frente a ausencia, IN homogéneo, análisis de intervalos simples y UNIQUE ambiguo. Ver [evaluador y límites](../specs/DECISION_EVALUATOR.md); el parser/publicador completo corresponde a T06. UNIQUE requiere defaultOutput; FIRST sólo se habilita seleccionándolo explícitamente como hitPolicy. Transiciones: id/from/event/allowedRoles/actions. Una ruta fija usa to + outcome; una ruta calculada usa decision + destinations y el output seleccionado es el outcome de negocio. No pueden mezclarse. Actions están ordenadas y pueden estar vacías para una captura pura. Guard es opcional y se evalúa antes de aceptar el evento. Updates opcionales se aplican tras las acciones/decisión. diff --git a/scripts/test_verify_acceptance.py b/scripts/test_verify_acceptance.py new file mode 100644 index 0000000..99d5111 --- /dev/null +++ b/scripts/test_verify_acceptance.py @@ -0,0 +1,35 @@ +import tempfile +from pathlib import Path +import unittest +from verify_acceptance import inspect + + +class ReportGateTest(unittest.TestCase): + def test_missing_reports_cannot_pass(self): + with tempfile.TemporaryDirectory() as tmp: + result = inspect(Path(tmp), {f"A{i:02}": ["Suite#case"] for i in range(1, 21)}) + self.assertFalse(result["passed"]) + + def test_passed_skipped_failed_and_external_reports(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for folder in ("surefire-reports", "failsafe-reports"): + (root / folder).mkdir() + (root / folder / "TEST-Suite.xml").write_text('') + manifest = {f"A{i:02}": ["Suite#case"] for i in range(1, 21)} + self.assertTrue(inspect(root, manifest)["passed"]) + extra = root / "surefire-reports/TEST-Extra.xml" + extra.write_text('') + self.assertFalse(inspect(root, manifest)["passed"], "Even an unmapped skipped test must fail the gate") + extra.unlink() + for status in ("failure", "error", "skipped"): + (root / "failsafe-reports/TEST-Suite.xml").write_text(f'<{status}/>') + self.assertFalse(inspect(root, manifest)["passed"]) + (root / "failsafe-reports/TEST-Suite.xml").write_text('') + self.assertEqual(["external-postgresql"], inspect(root, manifest)["environment"]) + manifest["A03"] = ["Suite#missing"] + self.assertFalse(inspect(root, manifest)["passed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_acceptance.py b/scripts/verify_acceptance.py new file mode 100644 index 0000000..fd1ae6a --- /dev/null +++ b/scripts/verify_acceptance.py @@ -0,0 +1,77 @@ +"""Check actual Maven reports against A01-A20; no third-party packages or credentials.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import sys +import xml.etree.ElementTree as ET + + +def inspect(root, manifest): + failures, found, totals, environments = [], {}, {"unit": 0, "integration": 0}, set() + expected = {f"A{i:02}" for i in range(1, 21)} + if set(manifest) != expected: + failures.append("Manifest must contain exactly A01-A20") + for group, folder in (("unit", "surefire-reports"), ("integration", "failsafe-reports")): + paths = sorted((root / folder).glob("TEST-*.xml")) + if not paths: + failures.append(f"Missing {folder}") + for path in paths: + suite = ET.parse(path).getroot() + for flag in ("failures", "errors", "skipped"): + if int(suite.get(flag, "0")): + failures.append(f"{suite.get('name')}: {flag}={suite.get(flag)}") + tests = suite.findall("testcase") + totals[group] += len(tests) + if group == "integration": + external = any(p.get("name") == "migration.test.external" and p.get("value") == "true" + for p in suite.findall("./properties/property")) + environments.add("external-postgresql" if external else "testcontainers") + for test in tests: + method = re.split(r"[\[(]", test.get("name", ""), maxsplit=1)[0] + key = test.get("classname", "").rsplit(".", 1)[-1] + "#" + method + ok = not any(test.find(tag) is not None for tag in ("failure", "error", "skipped")) + if not ok: + failures.append(f"Unsuccessful testcase: {key}") + found.setdefault(key, []).append(ok) + cases = {} + for case, references in sorted(manifest.items()): + if not references: + failures.append(f"{case}: no test references") + evidence = {} + for reference in references: + runs = found.get(reference, []) + evidence[reference] = {"executed": len(runs), "passed": sum(runs)} + if not runs or not all(runs): + failures.append(f"{case}: missing/failed/skipped {reference}") + cases[case] = evidence + if len(environments) != 1: + failures.append("Missing or mixed integration environments") + return {"passed": not failures, "environment": sorted(environments), "tests": totals, + "cases": cases, "failures": failures} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--reports", type=Path, default=Path("target")) + parser.add_argument("--manifest", type=Path, default=Path("specs/acceptance-cases.json")) + parser.add_argument("--output", type=Path, default=Path("target/acceptance-report.json")) + parser.add_argument("--require-testcontainers", action="store_true") + args = parser.parse_args() + result = inspect(args.reports, json.loads(args.manifest.read_text(encoding="utf-8-sig"))) + if args.require_testcontainers and result["environment"] != ["testcontainers"]: + result["passed"] = False + result["failures"].append("Docker/Testcontainers evidence required; external PostgreSQL is not equivalent") + result["manifestSha256"] = hashlib.sha256(args.manifest.read_bytes()).hexdigest() + jar = args.reports / "orquestador-nmp-0.1.0-SNAPSHOT.jar" + if jar.is_file(): + result["jarSha256"] = hashlib.sha256(jar.read_bytes()).hexdigest() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(json.dumps({k: result[k] for k in ("passed", "environment", "tests", "failures")})) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/specs/ACCEPTANCE.md b/specs/ACCEPTANCE.md new file mode 100644 index 0000000..f190023 --- /dev/null +++ b/specs/ACCEPTANCE.md @@ -0,0 +1,77 @@ +# Verificación integral T16 + +La fuente ejecutable es [acceptance-cases.json](acceptance-cases.json). Cada caso +A01–A20 referencia métodos de prueba concretos. El checker exige ejecuciones reales +en XML Surefire/Failsafe, rechaza cualquier fallo/error/skip y rechaza métodos +faltantes. No considera suficiente que exista el código fuente de una prueba. + +## Ejecución con Docker/Testcontainers (criterio de cierre) + +Requiere JDK 21, Python 3 y motor Docker operativo: + +```sh +docker info +sh ./mvnw --batch-mode --no-transfer-progress clean verify +python3 scripts/verify_acceptance.py --require-testcontainers +``` + +Windows: usar mvnw.cmd y python. La suite crea contenedores PostgreSQL 9.6.24 +temporales, JWT/JWKS locales, proveedores HTTP loopback y procesos JVM exclusivos +de pruebas. No necesita PostgreSQL productivo ni Keycloak real. Failsafe no omite +tests si Docker falta: falla. CI ubuntu-24.04 ejecuta estas mismas puertas, limita +el job a 30 min y conserva reportes/manifest por 30 días incluso al fallar Maven. +Una configuración de CI guardada no demuestra que el job remoto ya se ejecutó. + +## Verificación alternativa con PostgreSQL de pruebas + +```powershell +# Definir MIGRATION_TEST_DB_URL/USER/PASSWORD en el entorno del proceso. +# URL permitida por los fixtures: jdbc:postgresql://HOST:PORT/orquestador_test +./mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external +python scripts/verify_acceptance.py +``` + +Usa esquemas/roles temporales propios y los elimina al finalizar. Nunca usar una +base de runtime. Los secretos no se escriben en reportes de aceptación. El checker +infiere external-postgresql de las propiedades reales de Failsafe; si se agrega +--require-testcontainers a esta evidencia, devuelve error. La corrida alternativa +verifica comportamiento SQL/HTTP/JVM, pero no satisface por sí sola el requisito +de reproducibilidad Docker de T16. + +Ejecutar siempre clean verify antes del checker: no combinar reportes de corridas +parciales anteriores. acceptance-report.json guarda los casos, métodos/parametrizaciones +ejecutadas, totales, entorno y hashes de manifest/JAR. Archivar XML y reporte juntos. +El checker tiene pruebas propias: `python -m unittest discover -s scripts -p 'test_*.py'`. + +## Cobertura + +| Caso | Evidencia principal | +| --- | --- | +| A01 | DemoFlowsIT.creditBoundaries: cliente encontrado, persistencia y recorrido | +| A02 | DemoFlowsIT.missingCustomerRemainsCreatedAndCanBeRetried: SUCCEEDED/CLIENTE_NO_ENCONTRADO | +| A03 | DemoFlowsIT.customerApiFailureRetriesAndNeverBecomesCustomerNotFound: mock 503, tres intentos, FAILED | +| A04 | DemoFlowsIT.creditBoundaries + DecisionEvaluatorTest: 71/70/61/60 y decimales | +| A05 | DemoFlowsIT.invalidProviderScoreNeverDefaultsToManualReview | +| A06 | DecisionEvaluatorTest UNIQUE + FlowSimulationTest.ambiguousDraftDecisionIsNotConvertedIntoDefault | +| A07 | DemoFlowsIT.repeatedEventHasOneExecutionAndOneProviderCallEvenAfterCompletion | +| A08 | EventAcceptanceIT.concurrentDifferentPayloadsConflictAndNumericCanonicalReplayIsStable | +| A09 | EventAcceptanceIT.concurrentIdenticalAndDifferentKeysHaveOneAcceptance | +| A10 | DemoFlowsIT.existingCreditKeepsV1WhileNewCreditRequiresLicense | +| A11 | DocumentTasksIT: INVALID, documento ajeno/no conforme y referencia no reasignable | +| A12 | RetryReconciliationIT: caídas reales de JVM; WorkerIT: checkpoint durable | +| A13 | RetryReconciliationIT + WorkerIT: clave externa estable o conciliación | +| A14 | WorkerIT: SKIP LOCKED, renovación y fencing de worker obsoleto | +| A15 | EventAcceptanceIT, ObservabilityIT, SimulationIT: roles/ámbitos/propiedad | +| A16 | FlowPublicationIT.databaseAndApplicationBothPreventPublishedMutation | +| A17 | FlowPublicationIT: activación/retiro/asignación concurrentes | +| A18 | DemoFlowTest y DemoFlowsIT: v1/v2 con mismo motor/binario; licencia en JSON/perfil | +| A19 | WorkerIT y RetryReconciliationIT: rollback auditado y recuperación | +| A20 | DocumentTasksIT.clientCannotSubmitProviderFieldsOrReplaceMetadata | + +Las carreras usan barreras/latches y bloqueos DB; los retries adelantan next_attempt_at +en el esquema de pruebas sin esperar backoff real. CrashWorkerProcess usa Runtime.halt +en JVM hija; el padre reconstruye el worker desde PostgreSQL. No es una prueba de +reinicio de contenedor ni una garantía de exactly-once de un proveedor real. + +Pendiente histórico de T07 cerrado en implementación: [simulación](SIMULATION.md). +T17 conserva empaquetado/demo desde checkout limpio y verificación de imagen Docker. diff --git a/specs/ARCHITECTURE.md b/specs/ARCHITECTURE.md index 30460cf..a3147c8 100644 --- a/specs/ARCHITECTURE.md +++ b/specs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Arquitectura +# Arquitectura Un único módulo Maven, paquete raíz `com.organizacion.orchestrator`. La implementación sigue la especificación; esta entrega establece sus fronteras, no implementa todavía el motor. @@ -28,4 +28,48 @@ Una sola ejecución activa por solicitud. La incertidumbre de un efecto externo Publicación inmutable; instancias fijan flow_id. Activo se resuelve coherentemente en PostgreSQL. JSONB extiende datos; no permite scripts, clases, URLs o credenciales arbitrarios. Cambios de licencia con capacidades existentes no requieren columnas ni condiciones Java específicas. -JWT se valida por firma, expiración, issuer y audiencia. Actualmente sólo se expone salud mínima. Roles, scope y propiedad se implementarán antes de los endpoints de negocio. Sin entidades abiertas durante llamadas HTTP (`open-in-view=false`). +JWT se valida por firma, expiración, issuer y audiencia. T07 expone administración protegida y T08 creación/consulta de solicitudes. Roles, scope y propiedad se verifican antes de acceder a recursos; ambos grupos de rutas requieren habilitación explícita y runtime DB restringido. Sin entidades abiertas durante llamadas HTTP (`open-in-view=false`). + +## Dominio implementado en T04 + +Consultar [modelo y puertos](DOMAIN_MODEL.md). El dominio y los puertos son Java +puro; los records contienen snapshots inmutables. flow/api/ValidationReportResponse +es un DTO separado con conversión explícita y prueba contra el esquema REST. +Los adapters JPA/HTTP y servicios de aplicación se implementarán en sus tareas; +ningún record del dominio es entidad JPA ni se expone automáticamente por REST. + +## Administración interna de flujos T06 + +Consultar [validación y publicación](FLOW_PUBLICATION.md). FlowAdministration y sus +puertos son independientes de infraestructura. PostgresFlowStore implementa el +protocolo de locks/auditoría con JDBC transaccional (ADR-010). La exposición de +controladores administrativos queda ligada a T07, con autenticación/autorización +verificada; los contratos REST existentes no se modifican. + + +## Solicitudes T08 + +Requests y RequestStore mantienen la frontera pura de aplicación. PostgresRequestStore +comparte DataSource/transacción con FlowStore, serializa claves idempotentes en +PostgreSQL y confirma solicitud/auditoría/ledger en conjunto. RequestView es un DTO +explícito; su consulta usa un snapshot SQL para instancia, flujo fijado y ejecución +activa. Configuración de infraestructura común independiente del interruptor admin. +Detalles y decisiones: REQUESTS.md, SECURITY.md y ADR-012. + +## Aceptación de eventos T09 + +Events/EventStore y EventPolicy conservan aplicación/dominio puros. PostgresEventStore +bloquea la solicitud autorizada y confirma ejecución, revisión y auditoría juntas. +El replay lee estado actual antes de revisar la revisión esperada; las acciones +permanecen sin ejecutar. La definición se obtiene por flow_id fijado, también tras +retiro, sin invertir el orden de locks de publicación. Ver EVENTS.md y ADR-013. + +## Worker T10 + +WorkerStore separa reclamación, renovación, carga/checkpoints y confirmación atómica. +PostgresWorkerStore implementa fencing con tiempo DB. WorkerEngine resuelve mappings, +reutiliza decisiones T05 y valida datos finales. WorkerLoop limita concurrencia y +renueva durante acciones fuera de transacción. WorkerActions es el puerto para +adapters T11; el fallback no inventa resultados. ExecutionController expone una +proyección autorizada del estado. V3 conserva effect para recuperación segura. +Ver WORKER.md y ADR-014. diff --git a/specs/AVAILABLE_EVENTS.md b/specs/AVAILABLE_EVENTS.md new file mode 100644 index 0000000..86a4318 --- /dev/null +++ b/specs/AVAILABLE_EVENTS.md @@ -0,0 +1,83 @@ +# Eventos disponibles y tareas — T13 + +GET /api/v1/requests/{id}/available-events está habilitado con +REQUEST_API_ENABLED=true. Retorna AvailableEvents del contrato existente: +requestId, revision, events, blocked y, cuando aplica, blockedByExecutionId. +Requiere JWT válido y propiedad/ámbito de la solicitud; FLOW_ADMIN no permite +consultar recursos ajenos. + +Cada evento incluye su inputSchema publicado y, si hay una tarea asociada, +metadatos ui de FORM o DOCUMENT_UPLOAD. No se crea una interfaz gráfica, un +servidor de archivos ni otro motor de tareas. La aplicación cliente puede usar +estos datos para mostrar su formulario/carga documental. + +## Selección y consistencia + +- Usa el flow_id fijado a la solicitud, incluso si está RETIRED o existe una nueva + versión activa para otras solicitudes. +- Solo incluye transiciones del estado actual con un rol permitido del actor. + Los eventos de otros roles se omiten, sin exponer sus schemas o guardias. +- PENDING, RUNNING, RETRY_WAIT y RECONCILIATION_REQUIRED ocultan todos los eventos + y tareas; blockedByExecutionId identifica la ejecución activa. +- Una guardia evaluable con datos actuales de solicitud/actor que es falsa + devuelve GUARD_NOT_SATISFIED en blocked. Un error de evaluación devuelve + EVALUATION_ERROR, sin rutas, valores ni detalle interno. +- Si cualquier parte de la guardia depende de event.data, se difiere la guardia + completa al POST. El evento permanece visible para capturar esos datos. Esto + también aplica a EXISTS/NOT: ausencia de payload en GET no significa que el + futuro evento carecerá del campo. + +Una sola sentencia SQL obtiene solicitud, definición y ejecución activa con una +vista MVCC consistente, sin locks de fila. No llama proveedores. La respuesta es +orientativa y puede quedar desactualizada inmediatamente: POST revalida siempre +roles, revisión, estado, guardia y ausencia de otra ejecución. + +Cache-Control: no-store. No se emite ETag en este endpoint: disponibilidad cambia +por roles y estado técnico, aunque revision no cambie. Usar revision de la respuesta +para formar If-Match al enviar el evento, o volver a consultar GET de solicitud. +El POST puede responder 409/412 si hubo una carrera. + +## Documentos + +El cliente envía documentId conforme al inputSchema. No envía MIME, tamaño, +checksum, resultado antimalware, score o resultado de validación como autoridad. +REGISTER_DOCUMENT obtiene los metadatos del servicio documental configurado y +comprueba identidad, solicitud/ámbito, tipo, MIME, tamaño, checksum SHA-256 y +scanStatus=CLEAN. + +DocumentMetadata centraliza la verificación pura. El worker aplica adicionalmente +los límites más restrictivos del DOCUMENT_UPLOAD publicado antes de guardar +referencia/checkpoint, y también al reutilizar un checkpoint. Ejemplo: si el +catálogo admite 5 MiB y PNG/PDF, pero la tarea solo 1000 bytes/PDF, 1001 bytes o PNG +se rechazan. La referencia y su checkpoint mantienen la transacción/fencing T11. +Los registros existentes se reutilizan únicamente si todos sus metadatos coinciden. + +MIME real, checksum y análisis antimalware provienen del servicio documental +confiable. El orquestador no descarga bytes para recalcularlos ni ejecuta un +antivirus local. INFECTED/PENDING o metadatos inválidos no generan referencia ni +se convierten en un resultado de negocio VALID/INVALID. Sin proveedor configurado +no se fabrica éxito; los mocks existen únicamente en las pruebas. + +DOCUMENTO_VALIDA es una acción posterior independiente. INVALID técnicamente +válido puede producir SUCCEEDED/DOCUMENTO_INVALIDO y mantener el mismo estado/tarea; +las razones quedan en los datos de solicitud mediante mappings explícitos del +flujo. GET de solicitud expone esos datos autorizados. No se agregan campos de +razón libres al contrato de available-events. + +Un timeout/503 del validador produce reintentos técnicos y bloquea eventos; al +agotarlos de forma segura, FAILED conserva el estado previo y permite volver a +intentar. No se inventa INVALID ni se sobrescribe la última validación de negocio. +El checkpoint documental previo se conserva y no repite la consulta ya exitosa. + +## Verificación + +AvailableEventPolicyTest verifica selección pura; AvailableEventsIT comprueba +HTTP/JWT/PostgreSQL, versiones, roles, guardias y lectura frente a escritura +concurrente. DocumentTasksIT recorre registro→validación→decisión→tarea con HTTP +mock y PostgreSQL real: INVALID→nuevo evento VALID, referencias sin duplicar, +documentos ajenos, MIME/tamaño de tarea, checksum/antimalware, error técnico y +rechazo de payloads con datos de proveedor (A11/A20). + +Ejecutar mvnw.cmd clean verify -Pmigration-tests-external con credenciales de +orquestador_test solo en MIGRATION_TEST_DB_*. Sin ese perfil, verify requiere +Docker/Testcontainers. No se desplegó ni se usaron documentos o proveedores reales. diff --git a/specs/DECISIONS.md b/specs/DECISIONS.md index 42065a7..29cd5b8 100644 --- a/specs/DECISIONS.md +++ b/specs/DECISIONS.md @@ -82,3 +82,437 @@ Flyway 12.4.0 inspeccionado admite desde PostgreSQL 9.0 y la validación real so Verificación: clean verify con Java 21 (92 tests) y postgres96-check (1 prueba de integración compuesta). La prueba inicia el servicio local con pool read-only, Flyway automático desactivado, SQL init desactivado y validación Hibernate; después invoca exclusivamente Flyway.validate() con createSchemas=false. No ejecuta migrate/create/clean ni modifica la instancia externa. La primera propuesta de arranque fue rechazada por la revisión automática por riesgo de migraciones; se usó esta alternativa explícitamente de sólo lectura, aprobada y ejecutada. Fuentes: [dialectos y módulo comunitario Hibernate](https://docs.hibernate.org/stable/core/dialect/), [versiones de bases de datos Flyway](https://documentation.red-gate.com/fd/supported-databases-and-versions-143754067.html). La compatibilidad se comprobó con las dependencias reales del proyecto, no sólo con la documentación. + +## ADR-007 — Modelo persistente y pruebas aisladas T03 (2026-10-26) + +Decisión: implementar V1/V2 compatibles con PostgreSQL 9.6 y usar el esquema +orchestrator para tablas e historial Flyway. El arquetipo anterior no tenía +migraciones de negocio; si un entorno contiene historial real en public, requiere +un traslado explícito antes de actualizar, nunca baseline/repair automático. +Separar credenciales del migrador y runtime mediante DB_MIGRATION_*; conservar +fallback a DB_* para el entorno de desarrollo existente. Los roles se provisionan +externamente y grant_runtime_access concede permisos a un rol existente. + +La ausencia de Docker local se resuelve ejecutando las mismas pruebas sobre la +base orquestador_test cuya creación confirmó el usuario. Cada ejecución crea y +elimina su esquema t03_, sin tocar orquestador ni esquemas compartidos. La +prueba de permisos crea un rol NOLOGIN y revierte su transacción. La ruta normal +permanece en Testcontainers postgres:9.6.24 para CI; no se ejecutó localmente por +falta de Docker. La evidencia real corresponde a PostgreSQL 9.6.2 externo. + +Se rechazó usar H2 o cerrar por compilación: se probaron FKs, JSONB, historial +inmutable, permisos, migraciones desde cero, rollback de DDL, una carrera real de +inserciones y FOR UPDATE SKIP LOCKED con conexiones independientes. Los triggers +protegen integridad de almacenamiento; T06/T09/T10/T12 aún deben implementar +validación semántica, auditoría de negocio, aceptación, fencing y conciliación. +No se cambian contratos REST ni se declaran cumplidos A01–A20 por estas pruebas. + +## ADR-008 — Contratos internos inmutables (2026-10-26) + +T04 utiliza records y variantes selladas de Java 21. Se elige una representación +JSON propia e inmutable con BigDecimal, NullValue explícito y Lookup.Missing +separado, en lugar de exponer JsonNode mutable o Map en los puertos. +Esto mantiene snapshots confiables y permite compilar dominio/puertos sin librerías +externas. Los adapters futuros deberán convertir JSON y persistencia explícitamente. + +FlowDefinition modela configuración; FlowVersion modela su metadata persistida. +Las identidades FlowId/RequestId/ExecutionId/ActionExecutionId impiden intercambios +accidentales de UUID. Rutas, AST y acciones son variantes tipadas; estados/eventos +siguen siendo claves configurables. El catálogo confiable aporta clasificación +de efectos por separado, nunca mediante configuración editable del flujo. + +Errores técnicos y resultados de negocio tienen variantes distintas. Los puertos +mínimos se declaran sin implementaciones simuladas; T05/T06/T11 implementarán sus +comportamientos. El DTO de validación convierte el modelo al contrato publicado +sin exponer detalles internos. No se modifican DSL, OpenAPI, SQL, idempotencia, +versionado o política de autorización. Pruebas de arquitectura y de esquema +protegen estas fronteras. Ver DOMAIN_MODEL.md para invariantes y límites. + +## ADR-009 — Evaluación pura y presupuestos de AST/datos (2026-10-26) + +T05 implementa el puerto sin dependencias externas ni evaluación de expresiones +libres. Los once operadores usan JSON tipado; BigDecimal.compareTo evita coerción +y diferencias por escala. La ausencia es un error salvo EXISTS; null solo participa +en EQ/NEQ. Se concreta igualdad estructural para arrays/objetos y homogeneidad total +de IN, sin ocultar tipos inválidos tras una coincidencia temprana. + +El preflight iterativo limita la definición completa antes de aplicar cortocircuito: +profundidad 20 y 1000 nodos, incluyendo expresiones path/literal. Los límites pueden +reducirse por construcción. Se limita además IN a maxNodes elementos y la igualdad +estructural a maxNodes pares por comparación, para no recorrer datos arbitrariamente +grandes aunque el AST tenga pocas hojas. Exceso retorna EVALUATION_ERROR. Esta +restricción operativa queda explícita y no cambia schemas/SQL ni admite un default +como sustituto de un error. Límites de bytes de recepción quedan en T06/T09. + +Alternativas descartadas: recursión durante validación de profundidad (podría +desbordar antes del rechazo), validación solo de ramas ejecutadas (permitiría omitir +límites), igualdad BigDecimal.equals (depende de escala), coerciones y evaluación +arbitraria de scripts. IntervalOverlapAnalyzer analiza solo intervalos simples; +la ambigüedad UNIQUE sigue comprobándose al ejecutar. No promete resolver SAT. + +La evaluación conserva IDs de reglas para auditoría futura y snapshots intactos. +No se modifican las reglas de idempotencia, seguridad ni versionado. Detalle de +operadores, alcance de A04–A06 y conteo de límites: DECISION_EVALUATOR.md. + +## ADR-010 — Publicación transaccional y exposición administrativa (2026-10-26) + +T06 implementa un servicio interno de administración y un catálogo inmutable +proporcionado por el despliegue. No incluye controladores ni un catálogo ficticio +predeterminado. Aunque A16 menciona API, su evidencia en esta fase se obtiene en +la API Java de aplicación y en PostgreSQL; las rutas REST y la comprobación JWT +se realizan en T07/T16. Esta secuencia resuelve la exigencia explícita de seguridad +antes de exposición; no se da por aprobada la prueba HTTP que aún no existe. + +Desviación del uso propuesto de JPA para agregados: las operaciones administrativas +se implementan con el JDBC ya incluido en el stack y TransactionTemplate, detrás +de FlowStore. El motivo es mantener explícitos los bloqueos flujo/tipo, revalidación +de referencias, JSON canónico y auditoría en una única transacción sin flush implícito. +Alternativa: entidades JPA más queries nativas y control manual de flush; añade dos +modelos de escritura al mismo protocolo sin una necesidad actual. No se elimina JPA +ni se reemplaza el stack; otros agregados podrán usarlo sin cambiar los contratos. + +Activación y asignación bloquean flujo antes que tipo; retiro sigue ese orden y +limpia solo referencias que todavía apuntan a la versión. Incrementa revision de +los tipos desactivados y audita sus claves. Instancias existentes permanecen fijadas. +T08 debe completar creación/idempotencia/auditoría en la misma transacción; el puerto +no debe usarse como lectura desconectada seguida de una inserción posterior. + +La canonicalización es propia, determinista, con objetos ordenados, arrays ordenados +y números BigDecimal normalizados; no se anuncia RFC 8785. Defaults y contratos se +resuelven antes del hash. Entradas iniciales/de evento cerradas y protección de rutas +escritas desde results impiden confiar en campos de proveedor recibidos del cliente. +La validación estática de tipos no sustituye validación de valores en runtime. + +networknt 1.5.9 se reutiliza en runtime, con Jackson 2 aislado en infraestructura. +Schemas se empaquetan desde los archivos contractuales existentes. No cambian las +migraciones, idempotencia ni credenciales. Las pruebas reales usan exclusivamente +orquestador_test y esquemas temporales propios conforme a la autorización vigente. +Detalles, límites y protocolos: FLOW_PUBLICATION.md. + +## ADR-011 — Identidad y autorización T07 (2026-10-27) + +Aceptado para implementación local verificable. JWT RS256 validado por JWKS, issuer, +audience y exp; sub y un ámbito de negocio único son obligatorios. Se toma +business_scope por defecto, configurable con JWT_BUSINESS_SCOPE_CLAIM mientras se +confirma el claim real de controlms. OAuth scope no sustituye el ámbito ni concede +roles. Se admiten roles del realm y del cliente identificado por JWT_AUDIENCE. +Alternativa descartada: confiar en actor/scope enviados en el comando o usar la +clave pública estática sin posibilidad de actualización por JWKS. + +Los recursos exigen propietario Y ámbito, con 404 para ausencia o acceso ajeno. +No existe bypass implícito FLOW_ADMIN. EXECUTION_OPERATOR tiene una puerta separada +de conciliación dentro de su ámbito, sin requerir ser propietario; la operación +funcional y evidencia permanecen en T12. Alternativa descartada: cualquier usuario +del ámbito puede acceder a todas las solicitudes, pues ampliaría acceso sin una +regla de negocio explícita. + +El catálogo de flujos/tipos es global según el modelo actual. Su administración +requiere rol técnico y un ámbito en ADMIN_BUSINESS_SCOPES, lista vacía por defecto. +No se inventa tenancy para flow_definition. FLOW_ADMIN_ENABLED=false conserva las +rutas cerradas hasta que el operador configure catálogo, ámbito y credenciales DB +separadas. Con administración habilitada, un verificador rechaza al runtime elevado +y permisos de mutación de historiales; Flyway usa su propio usuario propietario. +No se cambia automáticamente la cuenta pgadmin existente ni permisos globales. + +Las seis operaciones administrativas T06 se exponen en T07. Simulación sigue pendiente +para T14 y conciliación para T12. A15 se verifica con la puerta de autorización real, +JWT firmado, recursos persistidos y un controlador exclusivo de test no distribuido; +las rutas funcionales posteriores deben reutilizarla y T16 repetirá la aceptación +integral. A16 se comprueba ahora por HTTP. Detalles: SECURITY.md. + +## ADR-012 — Creación idempotente y snapshot T08 (2026-10-27) + +La clave de creación se particiona por sujeto/ámbito y el hash incluye typeKey/data +canónicos, usando la semántica numérica T06. Se serializa con advisory lock +transaccional PostgreSQL de 64 bits y búsqueda/restricción única por identidad +completa. Una colisión del lock no mezcla identidades, solo retrasa transacciones. +Alternativas: mutex JVM (no protege varias instancias), reserva anticipada de ledger +(exigiría respuesta/solicitud incompletas o una migración adicional) e intentar insertar +sin serializar (recuperación de conflicto más compleja después de abortar transacción). + +La respuesta original queda durable en el ledger y se reproduce con 201/Location/ETag +originales; GET devuelve el estado actual. Se verifica acceso actual antes del replay. +La resolución de activo y validación por schema ocurren después de comprobar replay, +para que cambios posteriores de catálogo no invaliden una creación ya aceptada. La +clave distinta de contenido produce IDEMPOTENCY_CONFLICT. Fallos no consumen la clave. + +Solicitud, REQUEST_CREATED y ledger se guardan en una transacción READ COMMITTED, +con una misma DataSource que FlowStore. Se conserva orden flujo/tipo de T06. El cambio +de activo detectado durante asignación retorna 409 INVALID_FLOW_STATE reintentable, +sin registro parcial. El aislamiento de usuario/scope se aplica a cada consulta. + +Se extiende el adapter JDBC tras puertos de aplicación puros, en continuidad con +ADR-010, para compartir bloqueos, JSONB y commit con FlowStore. No se añade JPA para +un segundo modelo de escritura del mismo protocolo ni se altera el stack existente. +No hay nuevas migraciones: V1/V2 ya contienen ledger, FKs, auditoría y permisos. + +REQUEST_API_ENABLED es independiente de administración. Ambos requieren runtime DB +restringido; PersistenceAccessConfiguration factoriza este guard y los puertos comunes. +Creación exige identidad JWT válida, sin inventar rol nuevo ni ACL por tipo; los roles +de negocio se revalidan en eventos T09. Ver REQUESTS.md y contrato actualizado. + +## ADR-013 — Aceptación durable y revisión T09 (2026-10-27) + +Se mantiene el protocolo de la sección 8.1: bloquear solicitud autorizada, buscar +idempotencia antes de revisión, validar transición/rol/schema/guardia, insertar +PENDING, incrementar revisión y auditar en una transacción READ COMMITTED. El índice +único de ejecución activa y la clave request_id/idempotency_key existentes son +respaldo SQL; no hay mutex en memoria ni HTTP externo dentro de aceptación. + +accepted_revision guarda la revisión posterior a aceptación, If-Match + 1. El +worker deberá comparar esa revisión al confirmar; no es una revisión adicional +independiente. Hash SHA-256 de evento/data/revisión esperada canónicos, con misma +semántica decimal de T06/T08. El replay verifica acceso actual y actor original, +retorna status actual y no vuelve a exigir el rol/guardia de la aceptación previa: +no está autorizando un nuevo efecto. Otra revisión esperada con la misma clave +es otro comando y produce IDEMPOTENCY_CONFLICT. + +La definición se lee por flow_id fijado, incluyendo RETIRED, sin bloquear el flujo +ni consultar el tipo activo. No se altera estado/data de negocio al aceptar. Guardias +usan results vacío y los snapshots confiables de solicitud/evento/actor; errores de +evaluación no se convierten a false. No se guarda JWT completo, solo atributos +necesarios de identidad. Persistencia JDBC tras puertos puros continúa ADR-010/012. + +Alternativas descartadas: procesar acciones antes del 202 (mezcla transacción/HTTP), +revalidar revisión antes del replay (rompe reintento perdido), devolver un éxito de +negocio al aceptar y almacenar tokens para reintentos. No hay migración nueva: +V1/V2 cubren tabla, índices, FKs, permisos e inmutabilidad de auditoría. + +Location conserva el contrato de ejecución; su consulta GET se incorpora como +subtarea explícita T10 para seguimiento del worker. En T09 solo se implementa POST +de aceptación y GET de solicitud T08 permite ver activeExecutionId; no se anuncia +procesamiento ni consulta de ejecución implementados. Ver EVENTS.md. + +## ADR-014 — Worker, fencing y recuperación inicial T10 (2026-10-27) + +WorkerStore reclama PENDING/RETRY_WAIT vencidos y RUNNING expirados con SKIP LOCKED, +tiempo DB y token creciente. Los executors locales solo despachan capacidad libre; +PostgreSQL conserva cola, leases y checkpoints. Toda mutación exige owner/token +vigentes; checkpoints bloquean ejecución, finalización/fallo bloquean solicitud y +luego ejecución. Se comprueban revisión/estado al cargar y al confirmar. Nunca se +mantiene una transacción abierta mientras el adapter ejecuta una acción. + +V3 agrega effect al checkpoint, obtenido del adapter confiable y comparado al +reutilizarlo. Alternativa descartada: inferir seguridad desde el JSON editable o +suponer que un checkpoint antiguo era read-only. Los anteriores reciben clasificación +conservadora NON_IDEMPOTENT_WRITE. Hash/clave/effect son consistentes entre reintentos. +RUNNING/UNKNOWN no idempotentes requieren conciliación. Éxitos se reutilizan; cambios +de hash/effect se rechazan. Escrituras previas/potenciales impiden liberar como FAILED. + +El procesamiento secuencial resuelve inputs, valida contratos, guarda checkpoint, +evalúa decisiones y aplica updates sobre copia. Commit final incluye datos/estado, +revisión, resultado y auditoría con reglas/acciones. Errores DB conservan leases y +checkpoints para recuperar; no se transforman en éxito o en liberación incierta. +JSON Pointer permite actualizar objetos y posiciones existentes de arrays, sin +crear arrays dispersos. Se valida schema/256 KiB antes de confirmar. No hay claims +de exactly-once externo; la clave executionId:actionId solo funciona si el proveedor +respeta su contrato de idempotencia. + +WORKER_ENABLED independiente de API/admin, deshabilitado por defecto. Registro +productivo sin adapters hasta T11: ausencia produce INTEGRATION_UNAVAILABLE, nunca +mock de éxito. Flujos sin acciones ya pueden completarse. T12 conserva la política +completa de retry/backoff/Retry-After/conciliación; T10 implementa recuperación de +caídas y reconoce RETRY_WAIT existente. TechnicalFailure se termina de manera segura +sin anunciar retry HTTP aún. GET de ejecución ya se implementa con owner/scope y +proyección de respuesta, resolviendo la subtarea agregada en ADR-013. + +Pruebas: PostgreSQL real, dos reclamantes, checkpoints, retorno tardío y heartbeats; +adapters exclusivamente de test. SimulatedCrash y reconstrucción de repositorio no +se presentan como reinicio de JVM. WORKER.md documenta configuración y límites. + +## ADR-015 — T11: transporte acotado y contratos de despliegue (2026-10-27) + +Se conserva Spring Boot y sus beans, usando HttpClient del JDK 21 dentro del +adapter HTTP en lugar del cliente Spring propuesto en la especificación. Motivo: +controlar cancelación y límite de bytes antes de acumular una respuesta, además +de un deadline que incluya el cuerpo completo. No se agrega otra librería. +Alternativas: cliente Spring síncrono con lectura InputStream (requiere controlar +por separado el deadline de lectura) o WebClient (agrega stack reactivo). +La API BodySubscriber documenta cuándo se completa el cuerpo y cómo cancelar la +suscripción: [documentación JDK 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpResponse.BodySubscriber.html). +BoundedHttp usa un subscriber limitado y espera finita del futuro; las pruebas +incluyen headers inmediatos con cuerpo demorado y payload excesivo. + +La ruta es configuración confiable, ligada a tupla exacta de contrato/perfil. +No hay URLs, secretos ni selección de métodos HTTP en el DSL. El protocolo local +propuesto usa POST JSON y bearer opcional por referencia a variable de entorno. +No se inventa compatibilidad con proveedores existentes: ejemplos y mocks +definen el contrato que deberá confirmarse o adaptarse antes de habilitar rutas. +Nuevas versiones conviven; no cambiar el significado de una versión usada. + +REGISTER_DOCUMENT usa DOCUMENTS_METADATA/1 como protocolo fijo del handler, +porque el DSL de esta acción no tiene campos de versión. Cambiar ese protocolo +requiere versionar el handler/DSL mediante otra decisión, nunca seguir un default +mutable. Guarda referencia y checkpoint juntos bajo fencing. Su efecto remoto es +READ_ONLY; el registro local es idempotente y no sube archivos. T13 completa la +exposición documental. NO_OP permanece solo en simulación. + +A03 se divide explícitamente: T11 acredita transporte, clasificación y ausencia +de falsos negativos; T12 implementa programación de reintentos/backoff/Retry-After. +No se anuncia A03 integral terminado hasta T12/T16. No cambian las claves +idempotentes, versiones publicadas o autorización. Compose conserva un servicio +y PostgreSQL externo; sin despliegue ni llamadas reales. + +## ADR-016 — T12: reintento durable y resolución atómica (2026-10-27) + +Se programa RETRY_WAIT con reloj PostgreSQL en la misma transacción que el estado +del intento y RETRY_SCHEDULED, bajo fencing. No se duerme dentro del worker ni de +una transacción. Se conserva el límite publicado por acción, máximo tres intentos +totales; backoff exponencial con jitter 80–120 %, limitado por maxBackoff, y +Retry-After numérico/fecha HTTP con techo de 120 s. + +READ_ONLY e IDEMPOTENT_WRITE pueden reintentarse tras fallo transitorio. UNKNOWN y +RUNNING recuperado de NON_IDEMPOTENT_WRITE se concilian antes de evaluar agotamiento: +alcanzar el límite no demuestra ausencia de efecto. Un intento FAILED de escritura +idempotente agotada también conserva incertidumbre. Un resultado normalizado +negativo sigue siendo SUCCEEDED. Claves externas, hashes de entrada y versiones +publicadas no cambian. El hint Retry-After se añade al resultado técnico interno, +sin alterar el contrato público de ejecución. + +La evidencia de resolve es una declaración del operador autorizado con referencia +y razón; no existe servicio real configurado para verificarla automáticamente. +No fabricar una consulta de estado ni afirmar verificación remota. El operador +EXECUTION_OPERATOR accede por ámbito, incluso si no es propietario; además requiere +el interruptor administrativo y ADMIN_BUSINESS_SCOPES existentes. + +RESUME valida contratos fijados, efectos persistidos y cobertura de todos los +checkpoints no exitosos. No crea acciones no iniciadas, cambia resultados exitosos +ni inserta referencias documentales manuales. Reencola PENDING; el worker decide +el destino con el flujo publicado. CLOSE_WITHOUT_TRANSITION conserva negocio y +exige declaración ABSENT_VERIFIED/COMPENSATED_VERIFIED; ausencia contradicha por +una escritura SUCCEEDED durable se rechaza. No se agregan cambios de estado manuales. + +Se bloquea solicitud antes que ejecución. Checkpoints, ledger append-only, +auditoría y reencolado/cierre son atómicos; el fencing_token aumenta al resolver. +Replay se busca antes de comprobar estado actual, exige mismo comando canónico y +sujeto operador, y devuelve respuesta original. Otro sujeto con igual clave no +hereda el replay. El esquema V1 ya soporta esta semántica: no se necesita migración. +API informa 409 para estado no conciliable y 412 si se detecta revisión/estado +de solicitud incoherentes al reanudar; OpenAPI refleja ambas respuestas. + +Alternativas descartadas: reintentar toda la transición (duplica efectos previos), +liberar al agotar intentos sin considerar escrituras (pierde incertidumbre) y +permitir seleccionar destino desde resolve (evade reglas publicadas). La prueba +de caída usa una JVM hija real terminada con Runtime.halt; proveedor HTTP sintético +con efecto lógico por clave. No prueba un proveedor real ni un despliegue productivo. + +## ADR-017 — T13: disponibilidad orientativa y límites documentales publicados (2026-10-27) + +available-events es una proyección de backend del flujo fijado a la solicitud. +No se implementa interfaz gráfica. Una sentencia SQL obtiene revisión, estado, +datos, definición inmutable y ejecución activa en el mismo snapshot MVCC; no se +bloquean filas ni se consulta disponibilidad de proveedores. Se conserva acceso +por propietario/ámbito, sin bypass FLOW_ADMIN, y se omiten transiciones de otros roles. + +Con ejecución activa se ocultan todos los eventos y metadatos. Guardias que solo +usan solicitud/actor se evalúan; falso y error se proyectan como códigos genéricos. +Guardias con cualquier dependencia de event.data se difieren completas al POST. +Evaluarlas con un objeto vacío produciría falsos bloqueos, especialmente en +EXISTS/NOT. No se promete que un evento anunciado será aceptado: POST revalida +revisión, rol, guardia, estado e inactividad. + +No-store y ausencia de ETag en available-events: roles y estado técnico pueden +cambiar sin variar revision. Un ETag basado solo en revisión podría producir un +304 incorrecto. Se devuelve revision en el cuerpo para la precondición del POST. +No se cambia el contrato/ETag de GET de solicitud. + +DocumentMetadata extrae la verificación pura existente de T11. Se mantiene la +autoridad del servicio documental para MIME real/checksum/antimalware, sin descargar +archivos ni simular éxito productivo. El worker ahora verifica también MIME/tamaño +del DOCUMENT_UPLOAD publicado antes de guardar referencia/checkpoint y al +reutilizarlo. T11 aplicaba únicamente el límite general del catálogo; esto cerraba +insuficientemente tareas que anunciaban restricciones más estrictas. Se aplican +ambas políticas, no se amplía la autorización. + +El fixture documental separa documentId de entrada y registeredDocumentId verificado, +con validation protegida como salida del proveedor. No se relaja la regla de +publicación que impide escribir resultados desde datos del cliente. Los enums +status/scanStatus del catálogo de ejemplo agregan type:string explícito para el +análisis estático; sus valores aceptados no cambian. + +INVALID de DOCUMENTO_VALIDA conserva tarea/estado con razón mapeada a datos de +solicitud. Fallo técnico conserva los datos previos y sigue T12, sin fabricar +INVALID. Referencias y checkpoints conservan atomicidad/fencing T11; no se agrega +tabla ni endpoint de subida/descarga de archivos. Compose mantiene solo el servicio +con PostgreSQL externo. Proveedor documental real y antivirus real siguen fuera +de la evidencia local con mocks. + +## ADR-018 — Demostración por configuración y segundo tipo (2026-10-28) + +T14 entrega crédito v1/v2 y compra v1 como definiciones completas, sin modificar +src/main ni columnas. Se amplía solo el catálogo de ejemplo con INE/ESTADO_CUENTA +versión 1; LICENCIA_CONDUCIR ya existe. Todos los contratos y perfiles quedan +explícitos en JSON. Límites documentales: 5 MiB y MIME según catálogo/tarea. + +La especificación no define políticas de compra. El ejemplo usa consulta de cliente +y confirmación booleana, con finales confirmada/cancelada; no cobra ni reserva. +Alternativa descartada: inventar una integración de pagos o reglas comerciales. +Crédito exige una referencia de consentimiento; no afirma validar consentimiento +real. Los cuatro proveedores HTTP son fixtures sintéticos exclusivos de pruebas; +no se empaquetan en el servicio ni se activa configuración de demo por defecto. + +Se verifica la demo mediante API operativa y worker, con esquema temporal PostgreSQL, +en vez de presentar una simulación como recorrido persistente. El endpoint puro +/admin/flows/{id}/simulate sigue pendiente (anotado desde T07); no forma parte de +la evidencia A10/A18 ni se declara implementado. Se registra explícitamente para +la revisión integral T16 antes de la entrega final T17. + +## ADR-019 — Historial y observabilidad operativa (2026-10-28) + +El historial se proyecta desde audit_entry append-only con keyset (created_at,id), +límite 100 y filtros kind/execution/from/to. ResourceAccess autoriza propietario y +ámbito en cada página, sin bypass administrativo. El cursor codifica posición y +huella de solicitud/filtros; no es un token de acceso ni una firma criptográfica. +Se elige estabilidad entre reinicios/réplicas sin añadir secretos para una posición +que no amplía acceso. Alternativa: cursores firmados; no aporta autorización al +recurso y exige rotación compartida. Navegación no promete snapshot ante commits +concurrentes tardíos. La consulta tiene timeout SQL de 5 s. + +Se entrega lista permitida de detail; evidencia de conciliación y payloads no se +proyectan. Nuevas transiciones agregan reasonCodes presentes en resultados verificados +a la misma escritura auditada; no se alteran auditorías existentes. Actor se entrega +al propietario autorizado. Sin nueva migración ni modificación de semántica de +idempotencia/versiones. Retención no borra datos sin política de organización. + +Actuator metrics se habilita y protege con EXECUTION_OPERATOR + ámbito de operación +configurado, independiente de edición de flujos. Otros actuators se deniegan salvo +salud. Micrometer ya transitivo: sin dependencia nueva, Prometheus o servicio extra. +Contadores de comandos describen respuestas HTTP incluyendo replays; no representan +exactamente commits nuevos. Timers usan integrationKey confiable y resultado fijo; +estados DB son gauges agregados sin IDs/sujetos/ámbitos, con muestra compartida de +5 s y timeout SQL 3 s. Fallos dan available=0 y NaN, nunca un cero de cola ficticio. + +Logs propios usan campos permitidos y códigos; decisiones/razones quedan en auditoría. +Un log de acción exitosa no equivale a commit; phase=committed se emite después del +commit durable. Correlación HTTP queda ligada a executionId por EVENT_ACCEPTED. +WorkerShutdown amplía el drenaje existente con presupuesto configurable 1–20 s y +espera adicional de 1 s tras interrupción. Mantiene renovaciones durante el drenaje, +fencing/checkpoints y recuperación de incertidumbre. No se afirma cancelar efectos +externos no cooperativos ni probar Docker productivo mediante fixtures locales. + +## ADR-020 — Simulación y evidencia ejecutable de aceptación (2026-10-28) + +T16 cierra el endpoint de simulación pendiente desde T07. SimulationController exige +la seguridad administrativa existente; actor/results del body son fixtures. Se lee +el flujo y se libera el lock antes de evaluar. FlowSimulation no depende de HTTP, +secretos, worker actions o puertos de escritura. Valida schemas, versiones/perfiles, +roles/guardias y mappings; NO_OP usa output configurado solo en simulación. + +Se extrae mecánicamente la proyección final del worker a TransitionEvaluator, +compartiendo decisiones/updates/validación final/reglas y razones. Alternativa +rechazada: duplicar el evaluador en el endpoint, con riesgo de divergencia. La +extracción no modifica idempotencia, leases, efectos, transacciones o semántica +numérica. Simulación permite draft ambiguo para devolver AMBIGUOUS_DECISION, pero +publicación conserva su validación estricta. No crea auditoría ni registros de +negocio; es evaluación de fixtures, no evidencia de proveedor/documento real. + +acceptance-cases.json liga A01–A20 a métodos concretos. verify_acceptance.py inspecciona +XML real y falla por ausencia/error/skip; registra entorno y hashes. CI exige +Testcontainers y conserva artefactos 30 días. FailIfNoTests se habilita en ambos +plugins Maven. No se cambian versiones de stack ni se incorporan librerías nuevas. + +Docker no fue localizado en PATH, servicio o instalación estándar de este equipo. +Se solicitó un motor autorizado mientras se completa verificación con orquestador_test. +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. diff --git a/specs/DECISION_EVALUATOR.md b/specs/DECISION_EVALUATOR.md new file mode 100644 index 0000000..a29d08a --- /dev/null +++ b/specs/DECISION_EVALUATOR.md @@ -0,0 +1,92 @@ +# Evaluador de decisiones (T05) + +`PureDecisionEvaluator` implementa `DecisionEvaluator` sin Spring, I/O ni +persistencia. Es reutilizable entre workers; no guarda contexto ni resultados +entre llamadas. Devuelve `Selected(output, matchedRuleIds)` o `Failure(code)`. +`ConditionEvaluator` permite evaluar guardias: devuelve boolean o lanza +DomainException con EVALUATION_ERROR. No convierte errores en condiciones falsas. + +## Semántica ejecutable + +- EQ/NEQ comparan tipos JSON compatibles; números mediante BigDecimal.compareTo, + sin depender de la escala. No hay conversión de strings a números o fechas. +- Null es un valor presente. Solo EQ/NEQ lo comparan: null EQ null es true, + null EQ un valor no nulo es false. Una ruta ausente produce EVALUATION_ERROR + en cualquier comparación, incluso si el otro operando es null. +- EQ/NEQ admiten objetos y arrays estructurales, conservando el orden del array + e ignorando el orden de claves del objeto. Valores anidados con tipos + incompatibles producen error; formas/tamaños distintos producen desigualdad. +- GT/GTE/LT/LTE requieren dos números. No existe orden implícito de strings, + fechas, booleanos, objetos o null. +- IN requiere array homogéneo y un operando izquierdo compatible con sus elementos. + Revisa toda la lista antes de buscar: una coincidencia temprana no oculta tipos + mezclados. Null no participa en IN. Una lista vacía retorna false para un valor + no nulo. La igualdad numérica tampoco depende de escala dentro de IN. +- EXISTS retorna false solo para un path sin valor; un null explícito sí existe. + AND/OR cortocircuitan en orden; NOT invierte el resultado y propaga errores. +- UNIQUE examina reglas hasta detectar una segunda coincidencia, que retorna + AMBIGUOUS_DECISION incluso si ambas salidas son iguales. Si ninguna coincide, + usa defaultOutput. Un error previo a decidir el resultado no usa el default. +- FIRST se habilita únicamente con hitPolicy=FIRST y selecciona la primera regla + coincidente, sin evaluar los datos de reglas posteriores. La comprobación + estructural y de límites sí cubre la definición completa previamente. +- Selected conserva el ID de la regla elegida; lista vacía identifica el default. + La persistencia de esa evidencia en auditoría pertenece a las tareas posteriores. + +## JSON Pointer y contexto + +El resolver solo admite las raíces tipadas /request/data, /event/data, /results y +/actor. Desescapa ~1 y ~0 una sola vez y admite claves vacías y caracteres Unicode. +En arrays acepta índices decimales canónicos sin ceros iniciales; índices fuera de +rango, '-' y acceso a un hijo de un escalar no resuelven valor. No ejecuta expresiones. + +Actor expone subject, businessScope y roles provenientes del snapshot confiable; +roles se ordenan para que su representación sea determinista. Los campos del evento +no sustituyen esa identidad. Resolver raíz /request/data devuelve únicamente data, +no identidad, estado, revisión ni ACL de la solicitud. + +## Límites + +EvaluationLimits permite reducir límites hasta los techos operativos: profundidad +20 y 1000 nodos. Se configura al construir el evaluador; el wiring del motor vendrá +con los servicios que lo consumen. No es configuración editable del flujo. + +La raíz de cada condición tiene profundidad 1. Cada condición y cada expresión +path/literal cuenta como nodo; una comparación tiene tres nodos y profundidad 2. +Los literales son hojas del AST. Se suman todas las reglas y ocurrencias, aunque +compartan el mismo objeto Java. Los IDs duplicados son error. El preflight usa una +pila explícita y rechaza también ramas que un cortocircuito habría omitido. + +Para acotar comparaciones de datos, IN admite hasta maxNodes elementos y la +igualdad estructural hasta maxNodes pares visitados por comparación. Exceder el +presupuesto devuelve EVALUATION_ERROR. Esto no sustituye límites de bytes al +recibir/deserializar: definición 1 MiB y datos 256 KiB siguen pendientes de T06/T09. + +## Solapamientos estáticos + +IntervalOverlapAnalyzer reconoce EQ/GT/GTE/LT/LTE numéricos de path frente a literal, +operandos invertidos y conjunciones AND del mismo path. Intersecta extremos abiertos +y cerrados con BigDecimal y descarta intervalos vacíos. Devuelve parejas de IDs y +path con solapamiento demostrado, además de IDs de reglas fuera del subconjunto. + +No analiza OR/NOT/EXISTS/IN/NEQ ni compara intervalos sobre paths diferentes. +La ausencia de solapamientos encontrados no certifica disjunción general. T06 +consumirá estos diagnósticos al publicar; FIRST puede tener precedencia intencional. +UNIQUE mantiene obligatoriamente detección de ambigüedad en runtime. + +## Evidencia y alcance + +DecisionEvaluatorTest usa el flujo scoring.json contractual para 71/70/61/60 y +fracciones de precisión alta. Las fixtures missing-score/string-score se rechazan +por el schema BureauResult y, si llegan al evaluador, producen EVALUATION_ERROR, +nunca una salida de scoring bajo. No es todavía una prueba de adapter de buró. + +Las suites incluyen los once operadores, null/ausencia, IN heterogéneo, condiciones +anidadas, cortocircuito, FIRST/UNIQUE, límites exactos, 50000 niveles de entrada, +uso concurrente, JSON Pointer y extremos de intervalos. La prueba de arquitectura +incluye el evaluador de application y lo compila con únicamente java.base. + +No hay nuevos endpoints, llamadas externas, cambios SQL ni escritura de solicitudes. +El mapeo JSON a DSL usado por la prueba scoring es exclusivamente un fixture de +pruebas; el parser/validador completo de publicación corresponde a T06. Los criterios +A04–A06 se verifican al nivel de decisiones/contrato, no como flujo REST completo. diff --git a/specs/DOMAIN_MODEL.md b/specs/DOMAIN_MODEL.md new file mode 100644 index 0000000..a5d7f06 --- /dev/null +++ b/specs/DOMAIN_MODEL.md @@ -0,0 +1,94 @@ +# Modelo de dominio y puertos (T04) + +El dominio utiliza Java 21 y no depende de Spring, serializadores, HTTP, JDBC ni +JPA. Sus records son snapshots inmutables, no entidades administradas ni cuerpos +REST. Las escrituras y los bloqueos pertenecen a servicios de aplicación futuros. + +| Paquete | Modelos y responsabilidades | +|---|---| +| shared/domain | Identidades UUID distintas por recurso, Actor confiable, JSON inmutable, ErrorCode y DomainException | +| flow/domain | DSL FlowDefinition, estados/eventos, transiciones, rutas fijas o por decisión, tareas, schemas, FlowVersion y ValidationReport | +| decision/domain | AST cerrado de condiciones/valores, reglas ordenadas, política explícita UNIQUE/FIRST, contexto y resultado | +| action/domain | Configuración por capacidad, reintentos acotados, contexto, resultado y metadatos de catálogo confiable | +| request/domain | RequestInstance fijada a FlowId y RequestType con activación opcional | +| execution/domain | Estado técnico, ejecución aceptada, lease/fencing y checkpoint de acción | +| */application/port | DecisionEvaluator, ActionHandler y FlowDefinitionValidator | +| flow/api | ValidationReportResponse y conversión explícita desde dominio al contrato REST | + +## Invariantes y representación + +- Los estados y eventos de negocio son claves configurables, no enums Java por + proceso. Los enums representan únicamente conceptos técnicos cerrados. +- FlowDefinition representa el DSL; FlowVersion agrega identidad, estado de + publicación, revisión, checksum y fechas. RequestInstance conserva solo FlowId, + sin duplicar la versión. RETIRED es ejecutable para instancias existentes y no + asignable a nuevas solicitudes. +- List.copyOf y Map.copyOf/Set.copyOf impiden cambios por alias. Los hijos JSON + también son inmutables: objetos, listas, strings, BigDecimal, boolean y null + explícito. Lookup.Missing no es un valor JSON; no se confunde con NullValue. +- Los números se construyen con BigDecimal, sin coerción implícita desde strings. + La comparación numérica, resolución de pointers y límites de evaluación son T05. +- Condiciones, acciones y rutas son variantes selladas. EXISTS recibe un path; + NOT un operando; AND/OR conservan orden y requieren al menos dos argumentos. + Una transición tiene ruta fija o decisión/destinos, nunca ambas. +- Los paths de lectura se restringen a request.data, event.data, results y actor. + Updates.target es relativo a request.data; /id significa data.id y no request.id. + La detección de destinos solapados y la validación de datos corresponden a T06. +- Actor solo se construirá desde identidad verificada. No incluye JWT ni secretos + y no se toma del evento. EvaluationContext no expone servicios externos. +- Optional representa campos ausentes del modelo interno; los adapters deben + omitirlos o traducirlos según el contrato, no serializar el dominio directamente. + Versiones de contrato/perfil y schemas pueden faltar en borradores; T06 debe + resolverlos y fijarlos al publicar. SchemaVersion desconocida se diagnostica + en validación, no se interpreta como otra versión del proceso. +- ActionCapability procede del catálogo confiable. Su clasificación de efectos + y schemas no forman parte de ActionDefinition editable. NO_OP solo es para + simulación; T06 debe impedir publicarlo y T11 controlar su ejecución. +- ActionResult.Completed admite resultados negativos normalizados del negocio. + TechnicalFailure expresa fallo técnico TRANSIENT, PERMANENT o UNKNOWN_EFFECT. + La elegibilidad de reintento también requiere revisar efectos y checkpoints; + el record no autoriza por sí solo un reintento. +- ExecutionStatus.RECONCILIATION_REQUIRED sigue activo; SUCCEEDED/FAILED liberan + la exclusión. RUNNING exige lease y otros estados no lo admiten. SUCCEEDED + exige resultado, aunque el resultado conserve el estado anterior del negocio. +- Los hashes usan 64 caracteres hexadecimales en minúsculas, como SQL y API. + Los contadores no son negativos y los checkpoints exitosos exigen resultado y + fecha final. El worker debe verificar tokens y tiempos de PostgreSQL en T10. + +## Errores y adaptadores + +ErrorCode mantiene códigos de máquina independientes de HTTP. DomainException +expone solo el código, sin incorporar entradas ni respuestas crudas del proveedor. +Los adaptadores API resolverán status, correlationId y mensajes permitidos. +DecisionResult.Failure solo acepta EVALUATION_ERROR o AMBIGUOUS_DECISION; un +fallo nunca se representa mediante la ruta default. + +ValidationReport contiene incidencias con JSON Pointer, código y severidad. +ValidationReportResponse convierte solo errores al arreglo errors del contrato; +las advertencias internas no invalidan el flujo ni agregan campos no publicados. +El DTO se comprueba contra api.schema.json. No hay endpoint nuevo en T04. + +No hay anotaciones JPA ni entidades persistentes disfrazadas de records de dominio. +Los futuros adapters de persistencia deberán mapear explícitamente sus entidades +hacia estos snapshots; tampoco deben devolver entidades por REST. + +## Alcance verificado + +DomainModelTest cubre inmutabilidad, AST, pointers, ausencia/null, clasificación de +resultados, versiones, leases/checkpoints, reintentos y errores. DomainBoundaryTest +comprueba el DTO contra el esquema real. DomainArchitectureTest recompila todos +los archivos domain y application/port con classpath vacío y solo java.base; +además prohíbe referencias java.net/java.sql. Así detecta dependencias accidentales +de infraestructura, API, Spring/JPA o clientes HTTP. + +No hay implementaciones ficticias de los puertos ni llamadas a proveedores. +T05 implementará el evaluador; T06 el validador/publicación; T11 los handlers. +Los constructors garantizan integridad local del objeto; no sustituyen validación +del DSL, autorización, transacciones, canonicalización ni A01–A20. + +## Evaluación implementada en T05 + +PureDecisionEvaluator implementa el puerto de decisiones. ConditionEvaluator, +JsonPointerResolver, AstLimits y IntervalOverlapAnalyzer son componentes puros. +Ver [semántica y límites](DECISION_EVALUATOR.md). T06/T11 conservan la implementación +del validador y handlers; no se añadió ninguna llamada HTTP ni persistencia. diff --git a/specs/EVENTS.md b/specs/EVENTS.md new file mode 100644 index 0000000..584298d --- /dev/null +++ b/specs/EVENTS.md @@ -0,0 +1,86 @@ +# Aceptación durable de eventos — T09 + +Implementado `POST /api/v1/requests/{id}/events`, habilitado por +`REQUEST_API_ENABLED=true` junto con T08. Requiere JWT válido, propietario y ámbito +coincidentes, `Idempotency-Key` y un `If-Match` fuerte como `"0"`. + +```json +{"event":"SUBMIT","data":{}} +``` + +El evento y sus datos deben corresponder al flujo publicado de la solicitud. +No se aceptan estado, roles, identidad, resultados o transición desde el cliente. +Se aplican los mismos límites de T08: datos canónicos de 256 KiB, cuerpo acotado +a 256 KiB + 4096 bytes y profundidad JSON 64; rechazo de duplicados y UTF-8 inválido. + +## Protocolo + +1. Verificar acceso; bloquear la solicitud con SELECT FOR UPDATE, filtrando otra + vez propietario/ámbito dentro de la transacción. +2. Buscar la clave por request_id/idempotency_key. Mismo hash y sujeto autorizado: + devolver executionId y estado técnico actual. Hash distinto: 409 + IDEMPOTENCY_CONFLICT. El hash SHA-256 incluye evento, datos canónicos y revisión + esperada; cambiar If-Match también cambia el comando. +3. Sin replay, comprobar revisión (412) y ausencia de ejecución activa (409 + REQUEST_BUSY). La revisión se comprueba antes de busy, por lo que una carrera + con dos claves y la misma revisión produce un aceptado y otro 412. +4. Leer la definición inmutable fijada por flow_id, sin resolver el activo del tipo. + PUBLISHED y RETIRED pueden ejecutar instancias existentes. Resolver la transición + para estado/evento, exigir uno de allowedRoles (403), validar inputSchema (422) + y evaluar guardia pura con request.data/event.data/actor confiables. +5. Insertar execution PENDING con identidad mínima, evento, transición y estado + origen. Incrementar revisión y updated_at de solicitud, insertar EVENT_ACCEPTED. + Confirmar las tres escrituras en una sola transacción READ COMMITTED. +6. Retornar 202 con Location `/api/v1/executions/{id}` y AcceptedEvent contractual. + +accepted_revision guarda la revisión **posterior a aceptar** (If-Match + 1), que +T10 deberá verificar al confirmar trabajo. La solicitud conserva datos y estado +de negocio; T09 no ejecuta acciones, transiciones finales ni HTTP a proveedores. +No se guarda el JWT: actor_snapshot contiene subject, businessScope y roles. +La auditoría registra transición, revisión y correlationId, sin copiar datos del evento. + +## Reintentos y errores + +El replay se comprueba antes de revisión, busy, transición, roles y guardia actuales. +Devuelve el mismo executionId y status actual, incluso después de avanzar la +solicitud o terminar la ejecución. Exige siempre acceso actual y el sujeto original; +una revocación del rol de transición no vuelve a autorizar efectos, pues el replay +solo consulta la aceptación anterior. No incrementa revisión ni duplica auditoría. +Para reintentar un comando perdido, conservar también el If-Match original. + +Un evento nuevo usa otra clave. Mientras haya PENDING, RUNNING, RETRY_WAIT o +RECONCILIATION_REQUIRED no se admite otra ejecución. Las restricciones SQL existentes +son defensa adicional. Un error de persistencia revierte ejecución, revisión y +auditoría; no consume la clave. + +Evento no válido en el estado: 409 INVALID_EVENT. Guardia falsa: 422 +GUARD_NOT_SATISFIED; ruta ausente/tipo incompatible: 422 EVALUATION_ERROR, no una +condición falsa ni resultado de negocio. Sin If-Match: 428; débil/múltiple/inválido: +400. Recurso ajeno/inexistente: 404; sin autenticación: 401; infraestructura: 503 +sin exponer SQL. Revisión agotada en Long.MAX_VALUE no se incrementa y rechaza 409. + +## Alcance y verificación + +El worker T10 y las integraciones T11 permanecen pendientes. Sin worker, los eventos +aceptados permanecen PENDING. GET /requests/{id} muestra la revisión nueva y la +ejecución activa. La URI Location es la contractual; el endpoint GET /executions/{id} +todavía no está implementado y se completará con el seguimiento de ejecución T10. +No se afirma idempotencia de efectos externos hasta implementar sus protocolos. + +EventAcceptanceIT usa PostgreSQL real, login restringido, JWT RSA/JWKS local y HTTP +real. Reutiliza únicamente el fixture de T07/T08 en orquestador_test. Cubre A07–A09 +en aceptación: replay/409, carreras simultáneas, schemas y guardias, acceso/roles, +flujo retirado fijado, estados activos y rollback inducido al insertar ejecución, +actualizar revisión e insertar auditoría. Los cambios a SUCCEEDED/RUNNING/etc. son +fixtures SQL explícitos de prueba; no simulan un worker terminado en producción. +Una instancia nueva de adapter recupera el replay desde DB; no se anuncia una prueba +de reinicio del proceso ni procesamiento externo. T16 verificará el recorrido completo. + +Comando: `mvnw.cmd clean verify -Pmigration-tests-external` con variables +MIGRATION_TEST_DB_URL/USER/PASSWORD en entorno. Los esquemas/roles temporales propios +se eliminan al terminar. No se modifica orquestador ni se despliega el servicio. + +Desde T10 GET /executions/{id} y worker opcional ya están implementados. La sección +anterior de alcance corresponde al cierre de T09. Ver WORKER.md: sin WORKER_ENABLED +los aceptados siguen PENDING; con worker habilitado los flujos sin acciones pueden +concluir y las integraciones no registradas fallan explícitamente, sin mocks. diff --git a/specs/FLOW_PUBLICATION.md b/specs/FLOW_PUBLICATION.md new file mode 100644 index 0000000..2569902 --- /dev/null +++ b/specs/FLOW_PUBLICATION.md @@ -0,0 +1,132 @@ +# Validación y administración de flujos (T06) + +T06 entrega servicios internos verificables: FlowAdministration, FlowPreparation, +ConfiguredFlowValidator, FlowJsonCodec y PostgresFlowStore. Todavía no hay +controladores administrativos ni beans de catálogo predeterminados: T07 conectará +la identidad JWT verificada y la autorización antes de exponer REST. No se ofrece +un catálogo simulado en producción. Las firmas del servicio reciben Actor confiable, +revisión esperada y correlationId; nunca deben mapearse esos atributos desde el body. + +## Operaciones + +| Operación interna | Rol técnico | Efecto atómico | +|---|---|---| +| create | FLOW_EDITOR | Borrador y auditoría CREATE | +| edit | FLOW_EDITOR | Solo DRAFT, revisión esperada, identidad fija y auditoría EDIT | +| validate | FLOW_EDITOR o FLOW_PUBLISHER | Reporte de errores por JSON Pointer | +| publish | FLOW_PUBLISHER | Validar, fijar contratos/perfiles, checksum, PUBLISHED y auditoría | +| activate | FLOW_ADMIN | Flujo PUBLISHED, tipo habilitado/existente, revisión del tipo y auditoría | +| retire | FLOW_ADMIN | PUBLISHED a RETIRED, desactivar referencias activas y auditar | + +Los tipos se provisionan por migración/configuración administrativa, como establece +el contrato T02. Al retirar, cada tipo que aún apunta a esa versión queda sin flujo +activo e incrementa revision; su clave queda en admin_audit.detail.deactivatedTypeKeys. +La operación no cambia ninguna instancia existente ni desactiva tipos que ya apuntan +a otra versión. Toda escritura se revierte si falla insertar la auditoría. + +REVISION_CONFLICT es el código interno de revisión; el futuro adaptador REST lo +traducirá al REVISION_MISMATCH contractual (412). INVALID_FLOW, RESOURCE_NOT_FOUND, +ACCESS_DENIED y FlowValidationException tienen mensajes seguros. El adaptador debe +traducirlos al contrato y no devolver causas SQL. No reutilizar entidades/snapshots +como DTO REST. A16 tiene evidencia en servicio y DB; la comprobación HTTP con JWT +real se completa al exponer las rutas en T07/T16. + +## Catálogo confiable y publicación + +PublicationCatalog es un snapshot inmutable aportado por configuración confiable: +roles de negocio, handlers y sus schemas, integraciones por clave/versiones, +versiones de perfiles y políticas documentales (MIME/tamaño). No contiene endpoints, +headers ni secretos; no consulta disponibilidad de proveedores durante publicación. + +Versiones explícitas deben existir. Versiones omitidas en borrador se resuelven a +los defaults del catálogo y se guardan dentro de la definición publicada antes del +checksum. Rotar el default no cambia versiones ya guardadas. El despliegue deberá +conservar los contratos/perfiles requeridos por versiones en ejecución. Las fixtures +de prueba describen contratos de ejemplo; no son adapters ni respuestas de un buró. + +Se validan: + +- Estructura DSL, schemaVersion, límites, claves duplicadas y schemas embebidos. +- Identificadores únicos, estado inicial, destinos, alcanzabilidad, terminales, + estados sin salida y una transición por pareja estado/evento. +- Roles conocidos, handlers/contratos/perfiles resolubles y prohibición de NO_OP. +- Inputs obligatorios/permitidos, referencias a resultados anteriores, tipos de + mappings y literales contra su schema. No se permiten referencias adelantadas. +- Cobertura exacta de salidas de decisiones, límites del AST, tipos básicos y + solapamientos UNIQUE demostrables mediante intervalos simples. FIRST conserva + precedencia explícita; el evaluador conserva controles de runtime. +- Updates relativos a data, destinos existentes y no solapados. Las rutas escritas + desde results se consideran datos de proveedor: no pueden ser aceptadas en + initialDataSchema/eventos ni escritas luego desde un origen no confiable. +- Tareas ligadas a transición/evento y equivalencia canónica de inputSchema. + DOCUMENT_UPLOAD requiere documentId UUID obligatorio, mapping desde el evento, + REGISTER_DOCUMENT del mismo tipo y límites dentro de la política confiable. + +Publicación exige dataSchema de objeto e inputs iniciales/de eventos con type=object +expreso y additionalProperties=false. Si initialDataSchema falta se utiliza dataSchema; +si este permite recibir campos de proveedor, la publicación se rechaza hasta añadir +un schema inicial restrictivo. Los ejemplos estructurales de T02 no se convierten +silenciosamente en flujos publicables ni sustituyen los recorridos completos T14. + +La compatibilidad estática comprueba tipos y literales, no implica demostrar inclusión +arbitraria entre JSON Schemas ni resolver SAT. T08 debe validar datos iniciales contra +ambos schemas, T11 salidas normalizadas y T10 datos candidatos antes del commit. +La existencia de mappings válidos no autoriza a omitir esas validaciones en runtime. + +## JSON y checksum + +El decoder rechaza claves duplicadas, tokens adicionales, JSON de más de 1 MiB, +nesting JSON mayor a 64 y números fuera del presupuesto. El límite de AST de T05 +se aplica adicionalmente. Se rechazan NUL y surrogates aislados que no pueden +conservarse de forma segura en UTF-8/JSONB. Se usan los schemas originales empaquetados +en el JAR, sin duplicarlos ni aceptar referencias externas suministradas por un flujo. + +El formato canónico de la aplicación ordena claves de objetos, conserva orden de +arrays y normaliza decimales con BigDecimal sin ceros finales. Conjuntos del modelo +(roles/MIME) se ordenan. Defaults de reintento quedan explícitos al guardar el modelo. +SHA-256 se calcula sobre UTF-8 de esa representación. No se afirma conformidad JCS +(RFC 8785), cuyo modelo numérico es diferente. Cambiar esta canonicalización en el +futuro requiere versionar/migrar con cuidado, no recalcular versiones publicadas. + +## Transacciones y orden de bloqueos + +PostgresFlowStore usa JDBC y TransactionTemplate con READ COMMITTED. El puerto y +FlowAdministration siguen siendo Java puro. El motivo de esta decisión frente al +JPA propuesto para agregados está registrado en ADR-010; no se introduce otro stack. + +1. Edición/publicación bloquean flow_definition y verifican revision/estado. +2. Activación bloquea primero el flujo destino y después request_type; vuelve a + verificar revisión/habilitación antes de escribir. +3. Retiro bloquea el flujo y los tipos referenciados en orden de clave. El UPDATE + solo borra referencias que todavía apuntan a la versión retirada. +4. Para asignar una nueva instancia, assignableFlow lee el candidato sin bloqueo, + bloquea el flujo, bloquea el tipo y vuelve a verificar la referencia, enabled y + PUBLISHED. Un cambio concurrente produce conflicto, nunca una asignación ciega. + +T08 deberá invocar assignableFlow e insertar solicitud, idempotencia y auditoría +**dentro de la misma transacción y DataSource**. No obtener el FlowVersion, cerrar la +transacción y usarlo después para crear la solicitud. La sesión rechaza uso después +de su cierre. Si la creación gana el bloqueo, su instancia queda fijada y el retiro +posterior la respeta; si gana el retiro, no se permite la nueva asignación. + +No hay caché de activo. La resolución consulta PostgreSQL. No se modificaron V1/V2: +los triggers y permisos existentes continúan protegiendo versiones e historial. +Los administradores de DB conservan sus facultades; el servicio debe usar el rol +limitado documentado en db/migration/README.md. + +## Ejecutar verificaciones + +Con JDK 21, `./mvnw test` comprueba contratos, modelo, decisiones y validación T06. +`./mvnw clean verify` añade las dos suites PostgreSQL con Testcontainers. Sin Docker, +usar migration-tests-external y las variables MIGRATION_TEST_DB_* descritas en RUNBOOK. + +FlowPublicationIT solo opera sobre orquestador_test en un esquema t06_ propio, +que elimina al finalizar. Incluye fallos reales de auditoría mediante un trigger +exclusivo del esquema de pruebas, barreras de concurrencia y ambos órdenes de la +carrera asignación/retiro. No ejecuta migraciones sobre orquestador ni despliega el JAR. + +networknt 1.5.9 pasa de dependencia de test a runtime para validar publicación. +Jackson 2 queda confinado en ese adaptador; los records de dominio no dependen de él. +La configuración HTTP de Spring Boot continúa usando sus componentes existentes. +La prueba de arquitectura recompila también la administración de aplicación con +solo java.base, sin JDBC, Spring, serializadores ni API. diff --git a/specs/IMPLEMENTATION_PLAN.md b/specs/IMPLEMENTATION_PLAN.md index 76e9814..ce619b6 100644 --- a/specs/IMPLEMENTATION_PLAN.md +++ b/specs/IMPLEMENTATION_PLAN.md @@ -57,155 +57,192 @@ Dependencias: T02. Entregables: db/migration/, pruebas *IT. Entorno objetivo actualizado por el usuario: PostgreSQL 9.6.2 disponible en server02; ver ADR-006. Usar DDL compatible con 9.6 y un entorno aislado para pruebas de escritura. La comprobación de conexión/arranque E11 no cierra esta tarea. -- [ ] Crear tablas, FKs e índices. -- [ ] Añadir idempotencia de creación/admin_audit/reconciliation_resolution. -- [ ] Proteger inmutabilidad e historial con roles y triggers. -- [ ] Probar migraciones desde cero. +- [x] Crear tablas, FKs e índices. +- [x] Añadir idempotencia de creación/admin_audit/reconciliation_resolution. +- [x] Proteger inmutabilidad e historial con roles y triggers. +- [x] Probar migraciones desde cero. Verificación: Testcontainers PostgreSQL, rollback y migraciones. Cierre: Restricciones e inmutabilidad verificadas en PostgreSQL real. +Cerrado el 2026-10-26: 92 contratos y 40 pruebas de integración aprobadas mediante clean verify -Pmigration-tests-external sobre PostgreSQL 9.6.2 real en orquestador_test. Migraciones, rollback, permisos y concurrencia verificados; aislamiento y alternativa a Docker local documentados en ADR-007/E12. + ## T04 — Modelo de dominio y puertos Dependencias: T02,T03. Entregables: flow/, request/, execution/, decision/, action/. -- [ ] Definir records y errores estables. -- [ ] Separar DTOs y entidades. -- [ ] Implementar puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator. +- [x] Definir records y errores estables. +- [x] Separar DTOs y entidades. +- [x] Implementar puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator. Verificación: Pruebas de dominio y dependencias entre capas. Cierre: Dominio sin HTTP/JPA y contratos tipados. +Cerrado el 2026-10-26: mvnw test con Java 21; 134 pruebas aprobadas, incluidas 42 de dominio/arquitectura/frontera. Dominio y puertos recompilados sin infraestructura y DTO validado contra el contrato existente. No hay entidades JPA ni endpoints nuevos; implementaciones de puertos pendientes de T05/T06/T11. Evidencia E13 y ADR-008. + ## T05 — Evaluador de decisiones Dependencias: T04. Entregables: decision/, pruebas de AST. -- [ ] Implementar operadores/JSON Pointer/BigDecimal. -- [ ] Distinguir null y ausente. -- [ ] Aplicar límites y cortocircuito. -- [ ] Resolver UNIQUE/FIRST y ambigüedad. +- [x] Implementar operadores/JSON Pointer/BigDecimal. +- [x] Distinguir null y ausente. +- [x] Aplicar límites y cortocircuito. +- [x] Resolver UNIQUE/FIRST y ambigüedad. Verificación: JUnit parametrizado: 71/70/61/60, tipos y solapamientos. Cierre: A04–A06 y errores de evaluación comprobados. +Cerrado el 2026-10-26: mvnw test con Java 21; 216 pruebas aprobadas, incluidas 82 nuevas de decisiones/pointers/intervalos. A04–A06 verificados al nivel de evaluador puro y contrato de salida; no como flujo REST completo. Límites, cortocircuito y arquitectura comprobados. Ver E14, ADR-009 y DECISION_EVALUATOR.md. + ## T06 — Validador, publicación y activación Dependencias: T03,T04,T05. Entregables: flow/, admin_audit, pruebas *IT. -- [ ] Validar grafo, mappings, roles y capacidades. -- [ ] Editar borradores con revision. -- [ ] Publicar checksum canónico e inmutabilidad. -- [ ] Activar/retirar con bloqueos y auditoría. +- [x] Validar grafo, mappings, roles y capacidades. +- [x] Editar borradores con revision. +- [x] Publicar checksum canónico e inmutabilidad. +- [x] Activar/retirar con bloqueos y auditoría. Verificación: Pruebas PostgreSQL de carreras publicación/activación/retiro. Cierre: A16–A17; catálogo resoluble y publicación consistente. +Cerrado el 2026-10-26 para servicios internos: clean verify -Pmigration-tests-external, 258 pruebas unitarias + 51 IT PostgreSQL aprobadas (309 total). A16 verificado en servicio/DB y A17 con asignación transaccional real; exposición REST/JWT queda en T07 conforme a ADR-010. No se anuncia A16 HTTP completo ni integración de proveedor real. Evidencia E15 y FLOW_PUBLICATION.md. + ## T07 — Seguridad y autorización por recurso Dependencias: T04. Entregables: shared/infrastructure/security/, autorización por capacidad. -- [ ] Validar JWT real (firma/issuer/audience/exp). -- [ ] Mapear permisos técnicos y roles. -- [ ] Comprobar propiedad/scope en cada recurso. -- [ ] Separar rol DB de ejecución. +- [x] Validar JWT real (firma/issuer/audience/exp). +- [x] Mapear permisos técnicos y roles. +- [x] Conectar catálogo confiable/servicios T06 y exponer administración REST con pruebas JWT; completar A16 HTTP (ADR-010). +- [x] Comprobar propiedad/scope en cada recurso. +- [x] Separar rol DB de ejecución. Verificación: Tokens firmados válidos/expirados, otro issuer/audience, scope/rol ajenos. Cierre: A15; rutas protegidas antes de exposición. +Cerrado el 2026-10-27: 265 unitarias + 57 IT (322 total), sin fallos/errores/omisiones. Evidencia E16, SECURITY.md y ADR-011. A15 verificado mediante puerta real de recursos y controlador exclusivo de test; endpoints de negocio futuros deben integrarla. Keycloak real y despliegue pendientes. Simulación HTTP pendiente; seguimiento explícito en T16 (ADR-018). + ## T08 — Crear y consultar solicitudes Dependencias: T06,T07. Entregables: request/, repositorios y API. -- [ ] Resolver y fijar flujo activo. -- [ ] Validar datos iniciales. -- [ ] Idempotencia durable de creación. -- [ ] Exponer consulta y ETag con acceso por recurso. +- [x] Resolver y fijar flujo activo. +- [x] Validar datos iniciales. +- [x] Idempotencia durable de creación. +- [x] Exponer consulta y ETag con acceso por recurso. Verificación: IT creación repetida/conflictiva y activación concurrente. Cierre: Creación atómica e idempotente, versión fijada. +Cerrado el 2026-10-27: clean verify -Pmigration-tests-external, 268 unitarias + 66 IT (334 total), cero fallos/errores/omisiones. Creación/replay/consulta HTTP, activación y retiro con barreras, rollback SQL. Evidencia E17, REQUESTS.md y ADR-012. Sin despliegue. + ## T09 — Aceptar eventos idempotentes Dependencias: T08. Entregables: execution/api, aceptación, auditoría. -- [ ] Bloquear solicitud. -- [ ] Verificar hash antes de revisión. -- [ ] Validar If-Match/rol/guardia. -- [ ] Insertar PENDING, revision y auditoría atómicamente. -- [ ] Responder 202/Location. +- [x] Bloquear solicitud. +- [x] Verificar hash antes de revisión. +- [x] Validar If-Match/rol/guardia. +- [x] Insertar PENDING, revision y auditoría atómicamente. +- [x] Responder 202/Location. Verificación: IT duplicados, conflictos, concurrencia y rollback. Cierre: A07–A09; aceptación durable sin HTTP. +Cerrado el 2026-10-27: clean verify -Pmigration-tests-external, 268 unitarias + 76 IT (344 total), cero fallos/errores/omisiones. A07–A09 cubiertos en aceptación, sin acciones externas. Evidencia E18, EVENTS.md y ADR-013. Worker y GET ejecución pendientes T10; sin despliegue. + ## T10 — Worker PostgreSQL y leases Dependencias: T09. Entregables: execution/infrastructure, worker y repositorios. -- [ ] Reclamar con SKIP LOCKED. -- [ ] Renovar lease con tiempo DB. -- [ ] Exigir owner/token en escrituras. -- [ ] Checkpoints por acción y recuperación inicial. +- [x] Reclamar con SKIP LOCKED. +- [x] Renovar lease con tiempo DB. +- [x] Exigir owner/token en escrituras. +- [x] Checkpoints por acción y recuperación inicial. +- [x] Exponer GET /executions/{id} con autorización y estado técnico (seguimiento del Location T09; ADR-013). Verificación: IT dos workers, lease vencido y checkpoint; no @Async en memoria. Cierre: Cola durable multiinstancia; worker obsoleto no confirma. +Cerrado el 2026-10-27. Suite completa: 268 unitarias + 91 IT; tras dos controles adicionales, verificación final: 268 unitarias + 17 WorkerIT. Evidencia vigente de 361 casos distintos (268 + 93), todos aprobados. Ver E19, WORKER.md y ADR-014. Sin despliegue ni proveedores reales; adapters T11 y política completa de retry T12 pendientes. + ## T11 — Handlers e integraciones HTTP Dependencias: T10. Entregables: action/, integration/, mocks, compose.yaml. -- [ ] Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación. -- [ ] Fijar contratos/perfiles. -- [ ] Mapear entradas/salidas y timeouts. -- [ ] Añadir mocks. +- [x] Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación. +- [x] Fijar contratos/perfiles. +- [x] Mapear entradas/salidas y timeouts. +- [x] Añadir mocks. -Verificación: Mocks de cliente encontrado/no encontrado, timeout y salida inválida. Cierre: A01–A03; fallos técnicos separados del resultado de negocio. +Verificación: Mocks HTTP de cliente encontrado/no encontrado, timeout y salida +inválida; worker con PostgreSQL real, contratos fijados y rollback documental. +Cierre T11: A01/A02 y separación técnica de A03. La programación completa de +reintentos de A03 corresponde a T12; ver ADR-015. Proveedores reales requieren +contratos/credenciales confirmados y no son sustituidos por evidencia de mocks. + +Cerrado el 2026-10-27: 301 unitarias + 99 IT = 400 aprobadas en la suite completa, sin fallos/errores/omisiones. Evidencia E20, INTEGRATIONS.md y ADR-015. Mocks HTTP locales y PostgreSQL real; integración real pendiente de contratos/credenciales. Siguiente T12. ## T12 — Reintentos y conciliación Dependencias: T11. Entregables: execution/, endpoint resolve, pruebas *IT. -- [ ] Limitar intentos/backoff/jitter. -- [ ] Recuperar leases según clasificación de efecto. -- [ ] Tratar UNKNOWN. -- [ ] Conciliar con evidencia y autorización/idempotencia. +- [x] Limitar intentos/backoff/jitter. +- [x] Recuperar leases según clasificación de efecto. +- [x] Tratar UNKNOWN. +- [x] Conciliar con evidencia y autorización/idempotencia. Verificación: Fault injection antes/después de envío, checkpoint y commit. Cierre: A12–A14,A19; efectos inciertos no liberan solicitud. +Cerrado el 2026-10-27: 303 unitarias + 116 IT = 419 aprobadas; 17 casos T12 incluyen cinco caídas reales de JVM, retries, conciliación concurrente y rollback. Ver E21, RECONCILIATION.md y ADR-016. Sin despliegue ni proveedor real. Siguiente T13. + ## T13 — Documentos y tareas para front Dependencias: T11,T12. Entregables: document/, request/available-events. -- [ ] Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware. -- [ ] Guardar referencias. -- [ ] Exponer schemas/UI y bloqueos. -- [ ] Impedir escritura de resultados del proveedor. +- [x] Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware. +- [x] Guardar referencias. +- [x] Exponer schemas/UI y bloqueos. +- [x] Impedir escritura de resultados del proveedor. Verificación: Documento ajeno/inválido, ejecución activa y payload con score. Cierre: A11,A20; INVALID conserva tarea, error técnico no equivale a INVALID. +Cerrado el 2026-10-27: 308 unitarias + 137 IT = 445 aprobadas; incluye 8 casos de disponibilidad y 13 documentales con PostgreSQL real y mocks HTTP. Ver E22, AVAILABLE_EVENTS.md y ADR-017. Backend únicamente; sin interfaz gráfica, despliegue ni documentos reales. Siguiente T14. + ## T14 — Flujos demostrativos v1/v2 Dependencias: T05,T06,T13. Entregables: examples/flows/, examples/http/. -- [ ] Crear crédito v1, crédito v2 con licencia y compra v1 completos. -- [ ] Publicar/activar y recorrer. -- [ ] Demostrar coexistencia de versiones sin cambios Java/columnas. +- [x] Crear crédito v1, crédito v2 con licencia y compra v1 completos. +- [x] Publicar/activar y recorrer. +- [x] Demostrar coexistencia de versiones sin cambios Java/columnas. Verificación: Recorridos completos de v1/v2, licencia válida/inválida y fronteras. Cierre: A10,A18; dos tipos de solicitud publicables. +Cerrado el 2026-10-28: 311 unitarias + 29 IT = 340 aprobadas; demo 10, documentos 13 y HTTP 6. Ver E23 y ADR-018. Tres flujos publicados/activados y recorridos con mocks; src/main y JAR idénticos a T13. Siguiente T15. + ## T15 — Auditoría y observabilidad Dependencias: T09,T10,T11,T12,T13,T14. Entregables: audit/, métricas, logs, health. -- [ ] Completar consultas paginadas/filtradas. -- [ ] Métricas sin cardinalidad por solicitud. -- [ ] Logs sin PII. -- [ ] Readiness/liveness y apagado ordenado. +- [x] Completar consultas paginadas/filtradas. +- [x] Métricas sin cardinalidad por solicitud. +- [x] Logs sin PII. +- [x] Readiness/liveness y apagado ordenado. Verificación: Rollback/auditoría, acceso a historial, métricas y apagado. Cierre: Auditoría atómica desde T06 y operación observable. +Cerrado el 2026-10-28: 314 unitarias + 163 IT = 477 aprobadas, más 4 unitarias + 1 IT para el ajuste final de cursor. Ver E24, OBSERVABILITY.md y ADR-019. Historial/ACL, métricas, logs, DB-health, apagado y rollback verificados localmente; siguiente T16. + ## T16 — Verificación integral Dependencias: T15. Entregables: src/test/, CI, matriz A01–A20. -- [ ] Automatizar A01–A20. -- [ ] Reinicios y concurrencia deterministas. -- [ ] Verificar fronteras de rollback y seguridad. -- [ ] Conservar reportes. +- [x] Automatizar A01–A20. +- [x] Reinicios y concurrencia deterministas. +- [x] Verificar fronteras de rollback y seguridad. +- [x] Conservar reportes. +- [x] Cerrar pendiente del endpoint puro /admin/flows/{id}/simulate, registrado desde T07 (ADR-018); implementado/verificado por SimulationIT y FlowSimulationTest (ADR-020). + +- [ ] Ejecutar suite completa y gate A01–A20 con Docker/Testcontainers operativo. Verificación: ./mvnw clean verify con Docker, sin skips de integración. Cierre: Todos los casos obligatorios pasan en entorno reproducible. +Estado 2026-10-28: BLOCKED solo por entorno Docker. Implementación/simulación, CI y matriz completos; 493 pruebas Java y 2 del checker aprobadas con PostgreSQL externo. Testcontainers confirmó ausencia de motor válido. Ver E25, ACCEPTANCE.md y ADR-020; no equivale a cierre Docker. + ## T17 — Empaquetado y documentación Dependencias: T16. Entregables: Dockerfile, compose.yaml, README, RUNBOOK, examples/http, CI. diff --git a/specs/INTEGRATIONS.md b/specs/INTEGRATIONS.md new file mode 100644 index 0000000..d7cf86b --- /dev/null +++ b/specs/INTEGRATIONS.md @@ -0,0 +1,88 @@ +# Integraciones HTTP — T11 + +El worker utiliza `RegisteredWorkerActions`: `INVOKE_INTEGRATION` invoca HTTP y +`REGISTER_DOCUMENT` verifica metadatos y guarda una referencia bajo fencing. +`NO_OP` existe exclusivamente como utilidad de simulación y no está registrado +en Spring ni en el worker; la publicación continúa rechazándolo. + +## Configuración de despliegue + +Se necesitan dos archivos confiables, fuera de los eventos/definiciones editables: + +- `FLOW_CATALOG_PATH`: contratos JSON Schema, efectos, perfiles/versiones y políticas documentales. +- `INTEGRATION_ROUTES_PATH`: endpoint y límites para cada combinación exacta de integración, contrato, perfil y versión. + +Los archivos por defecto en `config/` tienen registros vacíos: no habilitan llamadas. +Los ejemplos de `examples/integrations/` describen un protocolo local propuesto, +no contratos confirmados de proveedores. Sus dominios `.invalid` son marcadores. +Copiar/adaptar ambos archivos, mantener todas las versiones usadas por solicitudes +vigentes y configurar las rutas antes de habilitar `WORKER_ENABLED=true`. +Cambiar un default no modifica las versiones fijadas al publicar. No sustituir +el significado de una versión existente: crear otra versión/tupla. + +Compose conserva un único servicio y PostgreSQL externo. Monta `./config:/app/config:ro`; +la ruta predeterminada dentro del contenedor es `/app/config/integration-routes.json`. +Los tokens se referencian por nombre de variable con `tokenEnvironment`. +Declarar explícitamente esa variable en el entorno del servicio Docker (un valor +en el .env de Compose no se transmite automáticamente al contenedor). No guardar +el token en los JSON, los flujos ni los eventos. Cadena vacía permite un endpoint +sin bearer cuando el contrato de despliegue lo requiera. + +## Protocolo y límites + +- POST JSON con inputs resueltos por el worker, validados contra el contrato. +- Headers: Idempotency-Key estable por ejecución/acción, X-Correlation-Id, + X-Integration-Profile y X-Profile-Version cuando corresponda; bearer opcional. +- HTTPS; HTTP solo loopback con perfil Spring `local` o `test`. Sin redirecciones, + userinfo, query o fragment en la URL. +- connectMs positivo; timeoutMs cubre headers y cuerpo completo, máximo 30 s y + menor que el lease. maxBytes limita memoria antes de superar 256 KiB. +- Respuesta JSON objeto, UTF-8 estricto, sin claves duplicadas ni coerción de + strings a números. Decimales conservan precisión. +- outputMappings mapea campos superiores de salida a JSON Pointers de la + respuesta. Vacío significa identidad. La salida normalizada debe cumplir schema. +- Contratos inline: no se resuelven referencias remotas desde el worker. + +Un 2xx válido con `found=false` o `status=INVALID` es éxito técnico y resultado +de negocio negativo. Un 404 es error salvo `notFoundOutput` explícito, permitido +solo para contratos READ_ONLY; ese resultado también se valida. No habilitar esa +traducción sin confirmar el contrato real. + +Timeout/429/500/502/503/504 son transitorios; con NON_IDEMPOTENT_WRITE se considera +efecto incierto. Una salida inválida tras escritura también es incierta. Los +códigos internos no exponen cuerpo remoto, URL o token. La clasificación no +garantiza idempotencia del proveedor: solo declarar IDEMPOTENT_WRITE si existe +garantía contractual para la clave enviada. + +**T12:** el worker programa RETRY_WAIT para fallos transitorios seguros, con +backoff/jitter y Retry-After acotados, hasta el máximo publicado de tres intentos +por acción. Conserva checkpoints; efectos inciertos requieren conciliación. +Consultar RECONCILIATION.md para el endpoint y la semántica de A03. + +## Documentos + +REGISTER_DOCUMENT consulta el protocolo fijo `DOCUMENTS_METADATA` contrato `1`, +sin perfil, con documentId/requestId/businessScope. El servicio documental debe +responder identidad de documento/solicitud/ámbito, tipo, identificador de storage, +MIME, tamaño entero, checksum SHA-256 hexadecimal y scanStatus=CLEAN. El handler +compara pertenencia y política; no confía en metadatos enviados por el cliente. + +La inserción en document_reference y el checkpoint SUCCEEDED comparten transacción +y fencing, con bloqueo de solicitud antes de ejecución. Una referencia existente +solo se reutiliza si coincide completamente. Fallo SQL revierte ambas escrituras. +No se descargan/suben archivos ni se ejecuta un antivirus: se verifica el dictamen +del servicio documental confiable. T13 completa eventos disponibles y tareas de front. + +## Verificación reproducible + +`mvnw.cmd test` ejecuta mocks HTTP loopback sin PostgreSQL ni proveedores reales. +`mvnw.cmd clean verify -Pmigration-tests-external` agrega PostgreSQL real utilizando +MIGRATION_TEST_DB_URL/USER/PASSWORD en el proceso y base exclusiva de pruebas. +Sin ese perfil, verify utiliza Testcontainers y requiere Docker. + +HttpIntegrationTest prueba transporte y contratos; RegisterDocumentTest verifica +metadatos y carga de ejemplos; HttpIntegrationIT conecta publicación, API de +solicitudes/eventos, worker, HTTP y PostgreSQL con rollback inducido. +Los estados START/END del fixture representan los destinos de A01/A02; los flujos +demostrativos con nombres funcionales corresponden a T14. No hay integración real +aprobada de clientes, documentos, buró o Keycloak ni despliegue en server02:8787. diff --git a/specs/OBSERVABILITY.md b/specs/OBSERVABILITY.md new file mode 100644 index 0000000..7fd7884 --- /dev/null +++ b/specs/OBSERVABILITY.md @@ -0,0 +1,100 @@ +# Auditoría y operación (T15) + +## Historial + +`GET /api/v1/requests/{id}/history` requiere REQUEST_API_ENABLED y JWT válido. +Aplica la misma propiedad y businessScope que GET de solicitud; FLOW_ADMIN y +EXECUTION_OPERATOR no tienen un bypass para consultar historial ajeno. Un recurso +ajeno/inexistente responde 404. La respuesta cumple HistoryPage y usa no-store. + +Filtros opcionales: kind, executionId, from inclusivo y to exclusivo (UTC terminado +en Z). limit por defecto 50, rango 1–100. Orden ascendente por (createdAt,id), +mediante keyset; cursor opaco de máximo 2000 caracteres ligado a solicitud y filtros. +Se permite cambiar limit conservando los demás filtros. Al terminar se omite +nextCursor. Cursor inválido o usado con otros filtros devuelve 400. + +El cursor representa posición, no autorización ni una credencial firmada. Cada +página vuelve a autorizar el recurso; modificar una posición nunca amplía su acceso. +Los inserts concurrentes pueden aparecer en páginas posteriores; no es una foto +transaccional de toda la navegación. Reiniciar desde from permite releer períodos +cuando hay transacciones que confirmaron tarde. El consumidor deduplica por id. + +Proyección de detail permitida: correlationId, transitionId, acceptedRevision, +ruleIds, reasonCodes, actionIds, hitPolicy, code, actionId, delayMs, mode y resolutionId. +Se omiten datos de entrada, resultados completos, tokens y evidencia del operador. +actorSubject se entrega únicamente al propietario autorizado. Nuevas transiciones +guardan reasonCodes de resultados de acciones cuando existen; no se reconstruyen +razones para auditorías históricas que no las contenían. + +Se mantienen las escrituras atómicas T06–T12 y triggers append-only V2. No hay una +segunda escritura de auditoría posterior al commit ni una migración nueva. El rol +runtime no modifica ni elimina historial. Retención/eliminación requiere una política +de datos aprobada y procedimiento privilegiado aparte; este servicio no borra +auditoría automáticamente ni interpreta un TTL como permiso de borrado. + +## Métricas + +`/actuator/metrics` y `/actuator/metrics/{name}` requieren EXECUTION_OPERATOR y +businessScope incluido en ADMIN_BUSINESS_SCOPES. No dependen de habilitar edición +de flujos. Actuator restante está denegado salvo las tres rutas públicas de salud. +No se agrega servidor Prometheus ni dependencia nueva; Micrometer/Actuator ya están +incluidos en el servicio. + +| Nombre | Tipo/etiquetas | Significado | +| --- | --- | --- | +| orchestrator.commands | Counter: command=create/event; result=accepted/rejected/error | Respuestas HTTP de comandos, incluidos replays; accepted es 2xx, error 5xx y rejected resto | +| orchestrator.executions | Gauge: state fijo | Filas durables por estado técnico, compartidas entre réplicas | +| orchestrator.pending.oldest.seconds | Gauge sin etiquetas | Edad DB del PENDING más antiguo; cero sin pendientes | +| orchestrator.leases.expired | Gauge sin etiquetas | RUNNING con lease vencido; no implica efecto externo duplicado | +| orchestrator.queue.sample.available | Gauge sin etiquetas | 1 muestra SQL disponible; 0 fallo; demás gauges NaN al fallar | +| orchestrator.integration.duration | Timer: integration, result=completed/failed/error | Tiempo del adapter HTTP, incluido contrato/mapeo; solo claves del catálogo/rutas permitidas | + +Los gauges se consultan bajo demanda y comparten una muestra SQL por proceso de +5 segundos; query timeout 3 segundos. Son estados actuales, no contadores históricos +de transiciones. RECONCILIATION_REQUIRED expone conciliaciones pendientes y RETRY_WAIT +reintentos programados. No hay etiquetas por UUID, sujeto, ámbito, URL, perfil, +excepción libre o datos de proveedor. Las claves de integración pertenecen a +configuración confiable. Contadores/timers locales se reinician con el proceso; +no son el ledger durable. Para varias réplicas no sumar gauges de la misma DB. + +## Logs y correlación + +El filtro genera X-Correlation-Id; no confía en uno suministrado por el cliente. +Comandos registran tipo, resultado HTTP, código de estado, correlación y duración. +Worker registra requestId, executionId, flowId, versión; acciones añaden actionId, +intento, resultado técnico y duración. `phase=committed` se emite después del commit. +Un log de acción completada puede preceder un rollback: consultar ejecución/historial +para el resultado durable. No registra payload, documento, sujeto, token, score, +evidencia ni excepción con body de proveedor. correlationId de EVENT_ACCEPTED enlaza +HTTP con executionId del historial y del worker; el adapter propaga executionId +como X-Correlation-Id estable entre intentos. Las decisiones y razones quedan en +auditoría restringida, no en logs. + +## Salud y apagado + +`/actuator/health/liveness` consulta disponibilidad del proceso, sin DB/proveedores. +`/actuator/health/readiness` añade PostgreSQL; falla 503 cuando DB no está disponible. +`/actuator/health` es salud agregada. Las rutas públicas omiten detalles/componentes. +No se llama a clientes/documentos/buró para determinar salud. + +Spring usa server.shutdown=graceful y 30 s por fase. El worker deja de reclamar, +cierra el poller (hasta 2 s) y conserva heartbeats mientras drena trabajos durante +WORKER_SHUTDOWN_SECONDS (1–20, predeterminado 20). Al agotar, interrumpe trabajos y +espera 1 s adicional; cancela renovaciones. Los adapters deben respetar interrupción +y sus límites de tiempo. Una operación no cooperativa puede seguir hasta su timeout; +no se presume cancelación de un efecto externo. Cola/checkpoints permanecen durables +y se recuperan por lease/fencing y conciliación T12. No se liberan leases por apagar. +Compose mantiene stop_grace_period=40s para la JVM y PostgreSQL externo. + +## Verificación + +ObservabilityIT usa API/JWT real, esquema y rol temporales en orquestador_test: +historial/ACL/filtros/cursor/empates, métricas SQL, Actuator y logs con sentinelas. +Cierra únicamente su propio pool para demostrar readiness 503/liveness 200. +ObservabilityTest usa latches y worker real con store controlado para drenaje, +renovación e interrupción dentro del presupuesto; HTTP loopback prueba timers. +WorkerIT conserva pruebas reales de rollback de auditoría, recuperación y fencing. +No es una prueba de despliegue Docker ni de caída de PostgreSQL de producción. + +Fuentes de API: [Micrometer](https://docs.micrometer.io/micrometer/reference/concepts/meters.html) +y [Actuator Health](https://docs.spring.io/spring-boot/api/rest/actuator/health.html). diff --git a/specs/PROGRESS.md b/specs/PROGRESS.md index a5bbe6b..c495240 100644 --- a/specs/PROGRESS.md +++ b/specs/PROGRESS.md @@ -1,6 +1,6 @@ # Seguimiento de implementación -Actualizado: 2026-10-26. T01 y T02 DONE. Siguiente tarea: T03 (PostgreSQL/Flyway). El motor y endpoints de negocio no están implementados. +Actualizado: 2026-10-28. T01–T15 DONE. T16 BLOCKED: implementación y A01–A20 aprobados con PostgreSQL externo; falta corrida Docker/Testcontainers. Ver E25. T17 PENDING; ninguna tarea principal IN_PROGRESS. Estados permitidos: PENDING, IN_PROGRESS, BLOCKED, DONE. No más de una tarea principal IN_PROGRESS. Consultar IMPLEMENTATION_PLAN para subtareas y criterios. @@ -8,20 +8,20 @@ Estados permitidos: PENDING, IN_PROGRESS, BLOCKED, DONE. No más de una tarea pr |---|---|---|---|---|---|---|---| | T01 | DONE | — | pom.xml, mvnw*, .mvn, configuración, DeploymentSmokeCheck.java, ADR-001/004 | E09: clean verify con JDK 21 y deployment-smoke | BUILD SUCCESS; 5 pruebas, 0 fallos, 0 errores, 0 omitidas | Ninguno para T01 | Iniciar T02: OpenAPI y JSON Schema | | T02 | DONE | T01 DONE | contracts/openapi.yaml, schemas/*.schema.json, examples/contracts, ContractValidationTest.java, ADR-005 | E10: clean verify con Java 21 | BUILD SUCCESS; 92 comprobaciones, 0 fallos/errores/omisiones | Ninguno para T02 | Iniciar T03: migraciones PostgreSQL y Testcontainers | -| T03 | PENDING | T02 DONE | db/migration/, pruebas *IT | E11 verifica conexión/arranque 9.6; migraciones pendientes | Compatibilidad inicial aprobada; modelo no implementado | Falta entorno aislado para pruebas de migraciones | Crear DDL compatible con PostgreSQL 9.6 según ADR-006 | -| T04 | PENDING | T02,T03 | flow/, request/, execution/, decision/, action/ | Pendiente: Pruebas de dominio y dependencias entre capas | No ejecutado | Dependencias sin completar | Definir records y errores estables | -| T05 | PENDING | T04 | decision/, pruebas de AST | Pendiente: JUnit parametrizado: 71/70/61/60, tipos y solapamientos | No ejecutado | Dependencias sin completar | Implementar operadores/JSON Pointer/BigDecimal | -| T06 | PENDING | T03,T04,T05 | flow/, admin_audit, pruebas *IT | Pendiente: Pruebas PostgreSQL de carreras publicación/activación/retiro | No ejecutado | Dependencias sin completar | Validar grafo, mappings, roles y capacidades | -| T07 | PENDING | T04 | shared/infrastructure/security/, autorización por capacidad | Pendiente: Tokens firmados válidos/expirados, otro issuer/audience, scope/rol ajenos | No ejecutado | Dependencias sin completar | Validar JWT real (firma/issuer/audience/exp) | -| T08 | PENDING | T06,T07 | request/, repositorios y API | Pendiente: IT creación repetida/conflictiva y activación concurrente | No ejecutado | Dependencias sin completar | Resolver y fijar flujo activo | -| T09 | PENDING | T08 | execution/api, aceptación, auditoría | Pendiente: IT duplicados, conflictos, concurrencia y rollback | No ejecutado | Dependencias sin completar | Bloquear solicitud | -| T10 | PENDING | T09 | execution/infrastructure, worker y repositorios | Pendiente: IT dos workers, lease vencido y checkpoint; no @Async en memoria | No ejecutado | Dependencias sin completar | Reclamar con SKIP LOCKED | -| T11 | PENDING | T10 | action/, integration/, mocks, compose.yaml | Pendiente: Mocks de cliente encontrado/no encontrado, timeout y salida inválida | No ejecutado | Dependencias sin completar | Implementar catálogo confiable y REGISTER_DOCUMENT/INVOKE_INTEGRATION/NO_OP sólo simulación | -| T12 | PENDING | T11 | execution/, endpoint resolve, pruebas *IT | Pendiente: Fault injection antes/después de envío, checkpoint y commit | No ejecutado | Dependencias sin completar | Limitar intentos/backoff/jitter | -| T13 | PENDING | T11,T12 | document/, request/available-events | Pendiente: Documento ajeno/inválido, ejecución activa y payload con score | No ejecutado | Dependencias sin completar | Verificar pertenencia/tipo/MIME/tamaño/checksum/antimalware | -| T14 | PENDING | T05,T06,T13 | examples/flows/, examples/http/ | Pendiente: Recorridos completos de v1/v2, licencia válida/inválida y fronteras | No ejecutado | Dependencias sin completar | Crear crédito v1, crédito v2 con licencia y compra v1 completos | -| T15 | PENDING | T09,T10,T11,T12,T13,T14 | audit/, métricas, logs, health | Pendiente: Rollback/auditoría, acceso a historial, métricas y apagado | No ejecutado | Dependencias sin completar | Completar consultas paginadas/filtradas | -| T16 | PENDING | T15 | src/test/, CI, matriz A01–A20 | Pendiente: ./mvnw clean verify con Docker, sin skips de integración | No ejecutado | Dependencias sin completar | Automatizar A01–A20 | +| T03 | DONE | T02 DONE | V1/V2 SQL, DatabaseMigrationIT.java, configuración, ADR-007, RUNBOOK | E12: clean verify -Pmigration-tests-external con JDK 21 | 92 contratos + 40 IT aprobados en PostgreSQL 9.6.2; 0 fallos/errores/omisiones | Ninguno para T03; ruta Docker no ejecutada localmente | T04: modelo de dominio y puertos | +| T04 | DONE | T02,T03 DONE | */domain, */application/port, flow/api/ValidationReportResponse, Domain*Test, DOMAIN_MODEL.md, ADR-008 | E13: mvnw test con JDK 21; compilación aislada y DTO contra schema | 134 pruebas aprobadas; 0 fallos/errores/omisiones | Ninguno | T05: implementar evaluador de decisiones | +| T05 | DONE | T04 DONE | decision/domain, PureDecisionEvaluator, tres suites T05, DECISION_EVALUATOR.md, ADR-009 | E14: mvnw test con JDK 21 | 216 pruebas aprobadas, 0 fallos/errores/omisiones; A04–A06 a nivel evaluador/contrato | Ninguno | T06: validador, publicación y activación | +| T06 | DONE | T03,T04,T05 DONE | flow/application, flow/infrastructure, PublicationCatalog, FlowValidationTest, FlowPublicationIT, FLOW_PUBLICATION.md, ADR-010 | E15: clean verify -Pmigration-tests-external | 258 unitarias + 51 IT aprobadas; A16 servicio/DB y A17 asignación; REST pendiente T07 | Ninguno para servicios internos T06 | T07: JWT, autorización por recurso y exposición administrativa | +| T07 | DONE | T04 DONE | shared/infrastructure/security, ResourceAccess, flow/api, SecurityPolicyTest, SecurityHttpIT, SECURITY.md, ADR-011 | E16: clean verify -Pmigration-tests-external | 265 unitarias + 57 IT aprobadas; JWT/HTTP/DB y A15 puerta de recursos/A16 HTTP | Keycloak real y despliegue pendientes, fuera de verificación local | T08: reutilizar autorización para solicitudes | +| T08 | DONE | T06,T07 DONE | request/application, request/api, PostgresRequestStore, RequestJson, PersistenceAccessConfiguration, RequestCreationIT, REQUESTS.md, ADR-012 | E17: clean verify -Pmigration-tests-external | 268 unitarias + 66 IT aprobadas; creación atómica, replay, concurrencia, ETag/ACL | Ninguno para implementación local; despliegue pendiente | T09: aceptar eventos idempotentes | +| T09 | DONE | T08 DONE | execution/api, Events/EventStore, EventPolicy, PostgresEventStore, EventAcceptanceIT, EVENTS.md, ADR-013 | E18: clean verify -Pmigration-tests-external | 268 unitarias + 76 IT aprobadas; A07–A09 aceptación durable, guardias, roles, rollback | Ninguno para aceptación local; worker y despliegue pendientes | T10: worker, leases y consulta de ejecución | +| T10 | DONE | T09 DONE | WorkerStore/PostgresWorkerStore, WorkerEngine/WorkerLoop, ExecutionController, V3, WorkerIT, WORKER.md, ADR-014 | E19: suite completa + verificación final WorkerIT | 268 unitarias y 93 IT distintos aprobados; worker final 17 casos, fencing/checkpoints/recuperación | Ninguno para worker local; adapters T11 y retry T12 pendientes | T11: handlers e integraciones HTTP | +| T11 | DONE | T10 DONE | action/infrastructure, integration/infrastructure, PostgresWorkerStore, mocks HTTP, examples/integrations, INTEGRATIONS.md, ADR-015 | E20: clean verify -Pmigration-tests-external | 301 unitarias + 99 IT = 400 aprobadas; 0 fallos/errores/omisiones | Integración real de proveedores pendiente de contratos/credenciales; sin bloqueo local | T12: reintentos y conciliación | +| T12 | DONE | T11 DONE | RetryDelay, WorkerEngine/Store, Resolutions/PostgresResolutionStore, ResolutionController, RetryReconciliationIT, RECONCILIATION.md, ADR-016 | E21: clean verify -Pmigration-tests-external | 303 unitarias + 116 IT = 419 aprobadas; retries, resolución atómica y 5 caídas de JVM reales | Ninguno local; proveedores reales fuera de alcance | T13: documentos y tareas para front | +| T13 | DONE | T11,T12 DONE | AvailableEvents/Policy/Store/Controller, DocumentMetadata, WorkerEngine, AvailableEventsIT/DocumentTasksIT, AVAILABLE_EVENTS.md, ADR-017 | E22: clean verify -Pmigration-tests-external | 308 unitarias + 137 IT = 445 aprobadas; A11/A20 y tareas/bloqueos verificados | Ninguno local; documental real fuera de alcance | T14: flujos demostrativos v1/v2 | +| T14 | DONE | T05,T06,T13 DONE | examples/flows, examples/integrations, demo-flows.http, DemoFlowTest/DemoFlowsIT, ADR-018 | E23: verify -Pmigration-tests-external -Dit.test=DemoFlowsIT,DocumentTasksIT,HttpIntegrationIT | 311 unitarias + 29 IT = 340 aprobadas; A10/A18, fronteras y dos tipos completos | Proveedores reales fuera del alcance local; simulate registrado para T16 | T15: auditoría y observabilidad | +| T15 | DONE | T09,T10,T11,T12,T13,T14 DONE | audit/api, HistoryStore, QueueMetrics, CommandObservationFilter, Worker/HTTP, ObservabilityTest/IT, OBSERVABILITY.md, ADR-019 | E24: clean verify -Pmigration-tests-external + check final cursor | 314 unitarias + 163 IT = 477; ajuste final 4 unitarias + 1 IT aprobados, sin fallos ni omisiones | Ninguno local; Docker/proveedores reales fuera de evidencia | T16: verificación integral | +| T16 | BLOCKED | T15 DONE | FlowSimulation/SimulationController, TransitionEvaluator, SimulationTest/IT, acceptance-cases.json, verify_acceptance.py, CI, ACCEPTANCE.md, ADR-020 | E25: clean verify externo + gate A01–A20 + prueba Docker | 326 unitarias + 167 IT = 493 aprobadas; A01–A20 PASS externo; 2 tests Python aprobados | Testcontainers no encuentra entorno Docker válido; CI remoto no ejecutado | Habilitar motor Docker autorizado y ejecutar clean verify + gate --require-testcontainers | | T17 | PENDING | T16 | Dockerfile, compose.yaml, README, RUNBOOK, examples/http, CI | Pendiente: Build de imagen, compose, verify y demo completa de sección 16 | No ejecutado | Dependencias sin completar | Construir imagen y Compose completos | ## E11 — Adaptación PostgreSQL 9.6.2 — 2026-10-26 13:23 (America/Mexico_City) @@ -96,9 +96,339 @@ Con la configuración del repositorio, readiness incluye PostgreSQL. Esta prueba - Siguiente paso: completar .env, ejecutar docker compose config --quiet y docker compose up --build -d; comprobar readiness y logs. T01 continúa BLOCKED y T17 PENDING hasta satisfacer sus criterios completos. 1. Leer este registro, especificación y cambios del repositorio. -2. T01/T02 DONE. Preparar T03: migraciones desde cero, constraints, índices, triggers y entorno PostgreSQL/Testcontainers. -3. Registrar evidencia de T03 y mantener una única tarea principal IN_PROGRESS. No repetir verificaciones aprobadas sin cambios o fallos que lo justifiquen. +2. T01–T10 DONE. Siguiente tarea habilitada: T11, handlers e integraciones HTTP. +3. Registrar evidencia de la siguiente tarea y mantener una única tarea principal IN_PROGRESS. No repetir verificaciones aprobadas sin cambios o fallos que lo justifiquen. ## Plantilla de evidencia adicional `Fecha | ID | Archivos | Comando/check exacto | Resultado y reporte | Bloqueo | Próximo paso` + +## E12 — Cierre T03 — 2026-10-26 14:12 (America/Mexico_City) + +- Entorno autorizado: server02:30432/orquestador_test, PostgreSQL 9.6.2 real; conexión inicial de solo lectura confirmó nombre y versión. No se modificó la base orquestador ni se desplegó el servicio remoto. +- Archivos: V1__orchestrator_tables.sql, V2__immutability_and_runtime_privileges.sql, DatabaseMigrationIT.java, pom.xml, application.yaml, compose.yaml, .env.example, probe de compatibilidad y documentación. +- Comando: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* en entorno de proceso. +- Resultado final: BUILD SUCCESS, 55.245 s, 92 pruebas Surefire y 40 Failsafe, cero fallos/errores/omisiones. Reportes: target/surefire-reports/com.organizacion.orchestrator.ContractValidationTest.txt y target/failsafe-reports/com.organizacion.orchestrator.DatabaseMigrationIT.txt. +- V1/V2 aplicadas desde esquema vacío y segunda ejecución sin migraciones pendientes. Verificadas diez tablas, FKs, JSONB/checks, idempotencia, índice activo incluyendo conciliación, flujos publicados/retirados inmutables, históricos append-only, rol runtime sin DDL ni cambios de auditoría, rollback atómico, rollback de migración fallida, carrera de dos inserciones y SKIP LOCKED. +- La primera corrida encontró una comparación textual false/f en la prueba JDBC de permisos; se corrigió con cast explícito a text. La suite completa final pasó, incluyendo los checks restantes del rol. +- El ERROR de Flyway V999 es intencional: el test comprueba que DDL e historial se revierten. V999 es temporal, no se entrega como migración. +- Esquema final aislado t03_304ad66fc7c3485ca63e474f2fe76c19 eliminado por AfterAll; rol temporal revertido y su ausencia comprobada. No se guardaron contraseñas en archivos del proyecto. +- Testcontainers 2.0.5/PostgreSQL 9.6.24 preparado como ruta normal de verify/CI; ejecución local no realizada por ausencia de Docker. ADR-007 documenta alternativa real externa y límites. +- Siguiente: T04 PENDING con dependencias satisfechas. T06 conserva la validación semántica/publicación y T07 la autorización por recurso. + +## E13 — Cierre T04 — 2026-10-26 15:52 (America/Mexico_City) + +- Criterios previos: records y errores estables; DTO separado de dominio/persistencia; puertos DecisionEvaluator, ActionHandler y FlowDefinitionValidator; dominio sin dependencias HTTP/JPA. Pruebas pertinentes: invariantes e inmutabilidad, recompilación aislada y compatibilidad DTO/schema. +- Implementación: 30 archivos de dominio/puertos bajo shared, flow, request, execution, decision y action; un DTO de validación en flow/api. JSON sellado e inmutable con BigDecimal y null/ausencia distintos; AST y acciones tipadas; identidad por recurso; snapshots de flujo, solicitud, ejecución y checkpoint. Ver DOMAIN_MODEL.md y ADR-008. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress test`, JAVA_HOME configurado al JDK local 21.0.12.1. +- Resultado: BUILD SUCCESS, 22.456 s, 134 tests (92 contractuales existentes + 39 DomainModelTest + 1 DomainArchitectureTest + 2 DomainBoundaryTest), cero fallos/errores/omisiones. Reportes en target/surefire-reports. +- Arquitectura: los 30 archivos domain/application/port recompilaron con classpath aislado y únicamente java.base, sin fuentes API/infraestructura. Se prohíben además referencias java.net/java.sql. No se añadió dependencia Maven. +- Verificado: copias inmutables de colecciones, precisión decimal, null/ausencia, pointers permitidos, orden de AST/reglas/acciones, políticas de reintento acotadas, publicación/retiro, ejecución activa y conciliación, lease consistente, resultado/checkpoint exitoso obligatorio, separación de fallo técnico/resultado negativo y hashes compatibles con DB/API. DTO de validación comprobado contra api.schema.json. +- Primera ejecución: 129 pruebas aprobadas. Revisión posterior alineó hashes a hexadecimal minúscula y añadió el DTO y cinco casos adicionales; la ejecución final de 134 pruebas aprobó todo. +- Alcance: sin cambios a SQL, contratos, configuración de despliegue o credenciales. No se accedió a PostgreSQL ni se repitieron sus IT de E12, al no cambiar persistencia. No se desplegó el servicio. Los puertos no tienen implementaciones simuladas; T05/T06/T11 conservan sus criterios funcionales y A01–A20 siguen pendientes. +- Siguiente tarea: T05 PENDING con dependencia satisfecha. No hay tarea principal IN_PROGRESS. + +## E14 — Cierre T05 — 2026-10-26 17:05 (America/Mexico_City) + +- Criterios previos: once operadores, JSON Pointer, BigDecimal, tipos estrictos, null/ausencia, cortocircuito, límites, UNIQUE/FIRST y ambigüedad. Verificar scoring 71/70/61/60 y solapamientos simples, con dominio independiente de infraestructura. +- Archivos: PureDecisionEvaluator, ConditionEvaluator, JsonPointerResolver, AstLimits, EvaluationLimits, IntervalOverlapAnalyzer; DecisionEvaluatorTest, JsonPointerResolverTest, IntervalOverlapAnalyzerTest; ampliación de DomainArchitectureTest; documentación y ADR-009. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress test`, JDK 21.0.12.1. BUILD SUCCESS, 13.414 s. 216 pruebas, cero fallos/errores/omisiones: 92 contratos + 56 decisiones + 17 pointers + 9 intervalos + 39 dominio + 1 arquitectura + 2 DTO/schema. +- Reportes: target/surefire-reports/com.organizacion.orchestrator.DecisionEvaluatorTest.txt, JsonPointerResolverTest.txt e IntervalOverlapAnalyzerTest.txt (mismo prefijo de paquete); pruebas previas también aprobadas. +- A04: fixture contractual scoring.json produce N/M/M/MANUAL para 71/70/61/60, además de fracciones precisas y valores fuera del rango ilustrativo. No se impuso rango universal 0–100. +- A05: fixtures de proveedor con score ausente/string rechazan BureauResult; si llegan al evaluador producen EVALUATION_ERROR, nunca default. Null también falla. No es aún prueba de adapter HTTP/proveedor. +- A06: varias coincidencias UNIQUE producen AMBIGUOUS_DECISION, incluso con igual output. FIRST conserva precedencia y regla aplicada; errores no se convierten a negocio negativo. Evaluación pura sin escrituras. +- Verificados límites exactos de 20 niveles/1000 nodos, suma de reglas y ramas no ejecutadas, entrada de 50000 niveles rechazada sin stack overflow, comparación estructural/IN acotados, cortocircuito ordenado y 40 evaluaciones concurrentes con la misma instancia/contexto intacto. +- Intervalos: extremos abiertos/cerrados, conjunciones, intervalos vacíos y operandos invertidos. Fuera del subconjunto se informa alcance desconocido; no se afirma satisfacibilidad general. Arquitectura recompila también el evaluador application con java.base y sin I/O/HTTP/JPA. +- Primera corrida: 215 pruebas aprobadas; se añadió rechazo temprano por número de reglas y una prueba de frontera exacta de nodos. Corrida final: 216 aprobadas. +- Sin cambios SQL, contratos REST/DSL ni dependencias; no se accedió a PostgreSQL ni proveedores, no hubo despliegue. Los IT PostgreSQL de E12 no se repitieron porque no cambió persistencia. A04–A06 están cubiertos al nivel T05; la aceptación integral REST permanece en T16. +- Siguiente: T06 PENDING con dependencias satisfechas. Ninguna tarea principal IN_PROGRESS. + +## E15 — Cierre T06 (servicios internos) — 2026-10-26 21:58 (America/Mexico_City) + +- Criterios previos: validar grafo/schemas/roles/capacidades/mappings; edición con revisión; publicar definición inmutable y checksum con contratos/perfiles fijados; activar/retirar con bloqueos y auditoría atómica. Verificar fallos y carreras en PostgreSQL real. ADR-010 explicita la separación entre servicio interno T06 y exposición REST segura T07. +- Archivos: PublicationCatalog, FlowJsonCodec, ConfiguredFlowValidator, FlowPreparation/FlowStore, CatalogFlowPreparation, FlowAdministration/FlowValidationException, PostgresFlowStore, FlowFixtures/FlowValidationTest/FlowPublicationIT, DomainArchitectureTest, pom.xml y documentación. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JAVA_HOME en JDK 21.0.12.1, credenciales MIGRATION_TEST_DB_* solo en entorno de proceso. +- Resultado final: BUILD SUCCESS, 2:07 min. 258 pruebas Surefire y 51 Failsafe, cero fallos/errores/omisiones. FlowValidationTest: 42 casos; FlowPublicationIT: 11 casos reales (47.86 s); DatabaseMigrationIT: 40 casos (58.63 s). Reportes: target/surefire-reports y target/failsafe-reports/com.organizacion.orchestrator.FlowPublicationIT.txt / DatabaseMigrationIT.txt. +- Validación: grafo, terminales/sumideros/alcanzabilidad, IDs/parejas únicas, roles, schemas cerrados, catálogo/perfiles, NO_OP, inputs y resultados previos, tipos, updates solapados, protección de datos de proveedor, tareas documentales, decisiones y límites. Decoder completo del DSL y roundtrip de fixtures T02, rechazo de claves duplicadas/JSON excesivo/referencias externas/Unicode incompatible, canonicalización y hash estable. +- Persistencia: ciclo crear/editar/publicar/activar/retirar, revisiones obsoletas, inmutabilidad de servicio y DB, permisos técnicos internos, contratos/perfiles fijados, retiro sin alterar instancias existentes y limpieza auditada de referencias activas. Cada escritura administrativa se revirtió ante un fallo SQL real inducido en admin_audit. +- Concurrencia con barreras: dos publicadores; editar/publicar; dos activaciones; activar/retirar; asignar antes del retiro; retiro antes de asignación. Ninguna asignación posterior al retiro ni cambio parcial. T08 debe conservar la misma transacción para solicitud/idempotencia/auditoría, según FLOW_PUBLICATION.md. +- A16 acreditado a nivel API interna y DB; comprobación REST/JWT no ejecutada ni anunciada como aprobada. A17 acreditado con puerta de asignación e inserción real de solicitudes en transacción. T07/T08/T16 completarán las rutas y el recorrido HTTP. No se inició T07. +- Aislamiento: únicamente orquestador_test. Último esquema T06: t06_ef065d4bc2c34f5dbbe3f839e7d1d96a, eliminado por AfterAll; también se limpió el esquema propio T03. Trigger de fallo solo en esquema de prueba. No se tocó orquestador ni se hizo redeploy. +- La primera solicitud de ejecución remota agotó el plazo de revisión automática y no se inició. El reintento permitido fue aprobado y pasó (255 unitarias + 50 IT); tras ampliar controles/auditoría y añadir el orden inverso de la carrera, la corrida final pasó con 309 pruebas. No queda bloqueo de aprobación. +- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 2E60C16853ECB1118922294996DF1041CA79A9CC9D71C394FD8C848EAF53B5DA. Inspección ZIP confirmó tres schemas empaquetados, networknt 1.5.9 y Jackson 2.21.7 junto al Jackson 3.1.7 de Boot. No se ejecutó Docker ni arranque/redeploy de este JAR. +- Decisión técnica: JDBC transaccional para administración, detrás de puertos puros, en lugar del JPA propuesto para estos agregados; motivo/alternativas documentados en ADR-010. No se añadieron migraciones ni se cambiaron contratos REST. No hay proveedor real ni catálogo simulado registrado en producción. +- Siguiente: T07 PENDING, dependencias satisfechas. Ninguna tarea principal IN_PROGRESS. + + +## E16 — Cierre T07 — 2026-10-27 02:47 (America/Mexico_City) + +- Criterios: JWT firmado (firma/issuer/audience/exp), roles técnicos y scope, puerta de propiedad para solicitud/ejecución/documento/auditoría, runtime DB restringido y seis rutas administrativas T06 con A16 por HTTP. ADR-011 registra decisiones de alcance y autorización. +- Archivos: SecurityConfiguration, JwtActorMapper, RuntimeRoleVerifier, ResourceAccess/ResourceOwnershipStore/ResourceOwner, PostgresResourceOwnershipStore, FlowAdministrationController/FlowView, CorrelationFilter/ProblemResponse/ApiExceptionHandler, PublicationCatalogLoader/FlowAdministrationConfiguration, config/, compose.yaml, application.yaml, .env.example, SecurityPolicyTest, SecurityHttpIT, ResourceProbe exclusivo de test, DomainArchitectureTest y SECURITY.md. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; credenciales MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 2:10 min, finalizado 2026-10-27T02:47:52-06:00. +- Resultado: 265 pruebas Surefire + 57 Failsafe = 322, cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, FlowPublicationIT 11, SecurityHttpIT 6. SecurityPolicyTest 7. Reportes target/surefire-reports y target/failsafe-reports. +- JWT RSA real y JWKS HTTP local: firma ajena, issuer/audience incorrectos, expiración, nbf futuro, ausencia de exp/sub/ámbito y token malformado/ausente denegados. Roles realm/cliente correcto aceptados; otros clientes y OAuth scope no conceden permisos. Ámbito administrativo ajeno denegado. +- HTTP administrativo: crear/validar/publicar/activar/retirar; rol editor no publica; ETag y precondiciones 428/400/412; editar publicado 409 (A16); JSON duplicado/malformado 400, schema inválido 422, cuerpo excesivo 413. Health anónimo 200, ausencia de cookies de sesión en errores autenticados. +- A15: los cuatro recursos persistidos resuelven propietario/ámbito a través de PostgreSQL. Dueño del mismo ámbito accede; mismo dueño de otro ámbito, otro dueño incluso FLOW_ADMIN y recurso inexistente reciben 404. Conciliación exige operador y ámbito, con delegación explícita de propietario. Se verifica por HTTP con ResourceProbe, controlador solo de test fuera del escaneo productivo, no son las rutas funcionales futuras. +- DB: conexión independiente con LOGIN temporal no elevado, concesiones V2, migración con propietario distinto; RuntimeRoleVerifier acepta runtime y rechaza propietario. DDL, DELETE admin_audit y lectura flyway_schema_history rechazados por PostgreSQL real. +- Aislamiento: únicamente orquestador_test; schema final t07_64e1695df2154c3ba0ad28c4530c0ef0 y rol t07_runtime_64e1695df2154c3ba0ad28c4530c0ef0 eliminados por AfterAll. Esquemas propios T03/T06 también limpiados. No se modificó orquestador, no se desplegó ni se invocaron proveedores. +- Corrida inicial aprobada: 265 unitarias; integración dirigida 4 casos aprobados el 2026-10-26. Se amplió con recursos persistidos y roles de cliente; la corrida final incluye esos 6 casos y todas las regresiones. El wrapper requirió ejecución fuera del sandbox para acceder a la caché/JDK; revisión automática aprobó ejecución. No queda bloqueo técnico. +- Configuración local: administración deshabilitada por defecto; al habilitar exige usuario DB restringido, catálogo y lista ADMIN_BUSINESS_SCOPES. business_scope es el claim por defecto configurable, pendiente de confirmar contra un JWT real de controlms. Verificar emisión real Keycloak y desplegar no se presentan como pruebas aprobadas. +- Sin nuevas migraciones ni cambio de idempotencia/versionado. Compose conserva un único servicio y PostgreSQL externo. Simulación se mantiene pendiente T14 y resolución funcional T12. T08 debe integrar la puerta ResourceAccess y desacoplar su wiring del interruptor administrativo conservando la verificación DB. Siguiente: T08 PENDING; ninguna tarea principal IN_PROGRESS. + +## E17 — Cierre T08 — 2026-10-27 03:03 (America/Mexico_City) + +- Criterios previos: resolver/fijar flujo publicado activo, validar initialDataSchema y dataSchema, idempotencia por actor/ámbito, solicitud/auditoría/ledger atómicos, consulta con ETag/ejecución activa y autorización. Pruebas de carreras reales, rollback y HTTP firmado; no cerrar por compilación. +- Archivos: Requests/RequestStore/RequestSnapshot, RequestJson/PostgresRequestStore/RequestConfiguration, RequestController/RequestView, PersistenceAccessConfiguration, ajustes FlowAdministrationConfiguration/ApiExceptionHandler, flags application.yaml/compose/.env.example; RequestJsonTest, RequestCreationIT, fixture SecurityHttpIT y DomainArchitectureTest. Contrato OpenAPI/README, REQUESTS.md, SECURITY.md, ADR-012 y examples/http/requests.http actualizados. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`; JDK 21.0.12.1, MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 2:34 min, finalizado 2026-10-27T03:03:36-06:00. +- Resultado: 268 unitarias Surefire + 66 IT Failsafe = 334; cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, FlowPublicationIT 11, RequestCreationIT 9, SecurityHttpIT 6. Reportes target/surefire-reports y target/failsafe-reports. +- HTTP: 201/Location/ETag, GET autorizado, RequestView/Problem contra schema contractual; replay idéntico con respuesta original después de activar v2, retirar v1 o avanzar revisión; GET conserva versión fijada y expone ejecución activa/ETag actuales. Conflicto 409 por otro contenido; clave independiente por actor/ámbito; scope/owner ajenos 404, sin JWT 401. Administración apagada 403 mientras solicitudes funcionan. +- Entrada: command cerrado, identidad cliente rechazada, ambos schemas de datos, tipos estrictos, score no permitido, tamaño UTF-8/canónico, JSON duplicado y decimal preciso. Ausencia de clave 400, schema 422, tamaño 413, tipo inexistente 404, tipo deshabilitado/sin activo 409. Fallos no consumen clave; replay no depende de disponibilidad del catálogo activo. +- Concurrencia real: dos POST de igual clave generan una única solicitud/respuesta; comandos distintos en carrera producen 201/409. Barreras dentro de la asignación comprueban retención de locks flujo/tipo hasta commit ante activación y retiro. Solicitud aceptada conserva versión anterior; posteriores usan nueva versión o rechazan tipo retirado. Regresiones de carreras T06 también aprobadas. +- Atomicidad: triggers exclusivos de prueba fallan realmente al insertar auditoría o ledger. No queda solicitud/ledger parcial; reintento tras fallo de auditoría funciona. Implementación JDBC comparte DataSource/READ COMMITTED con FlowStore y usa advisory lock transaccional más clave única completa; ADR-012 registra elección y semántica de replay. +- Aislamiento: únicamente orquestador_test. Se reutilizó fixture T07 con schema t07_704fbd4eaf8e4f649d08b40a78a6bf4c y rol LOGIN t07_runtime_704fbd4eaf8e4f649d08b40a78a6bf4c, eliminados por AfterAll de cada suite; T07 recrea/limpia esos objetos propios para regresión. T03/T06 limpian sus esquemas propios. No se tocó orquestador ni se desplegó en server02:8787. +- Primera corrida: 268 unitarias aprobadas; integración dirigida inicial de 7 casos aprobada (51.694 s). Se añadieron validación de esquema inicial y retiro concurrente; corrida final de 9 casos T08 y todas las regresiones aprobada. No quedan fallos conocidos ni bloqueos externos de prueba. +- No se necesitaron nuevas migraciones: V1/V2 contienen tabla idempotente, auditoría, permisos y restricciones. No hay proveedores ni mocks de resultados de negocio. JWT real firmado con JWKS local, no emisión real de controlms. Docker no ejecutado; Compose solo servicio con PostgreSQL externo y REQUEST_API_ENABLED=false por defecto. Para habilitar, configurar runtime restringido; independiente de administración. +- Siguiente: T09 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. Eventos, worker y recorrido integral siguen pendientes; no se anuncian cubiertos por T08. + +## E18 — Cierre T09 — 2026-10-27 03:36 (America/Mexico_City) + +- Criterios previos: bloquear solicitud autorizada, replay antes de revisión, If-Match/rol/schema/guardia, ejecución PENDING + revisión + EVENT_ACCEPTED atómicos, respuesta 202/Location. Verificar A07–A09 con PostgreSQL real y rollback, sin HTTP de proveedores. +- Archivos: AcceptedEvent/EventPolicy, Events/EventStore, PostgresEventStore/EventConfiguration, EventController, ApiExceptionHandler; EventAcceptanceIT y ampliación DomainArchitectureTest. Contrato OpenAPI, EVENTS.md, ADR-013, ejemplos HTTP y documentación actualizados. No se reemplazó trabajo previo. +- Comando final: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* solo en entorno de proceso. BUILD SUCCESS, 7:31 min; finalizado 2026-10-27T03:36:50-06:00. +- Resultado: 268 unitarias Surefire + 76 IT Failsafe = 344, cero fallos/errores/omisiones. IT: DatabaseMigrationIT 40, EventAcceptanceIT 10, FlowPublicationIT 11, RequestCreationIT 9, SecurityHttpIT 6. Reportes target/surefire-reports y target/failsafe-reports, particularmente com.organizacion.orchestrator.EventAcceptanceIT.txt. +- A07: reintento devuelve mismo executionId y status actual sin nuevas escrituras, incluso tras cambiar estado/revisión/rol. Acceso actual y sujeto original se comprueban siempre. Nueva instancia del adapter recupera replay desde PostgreSQL; no se anuncia reinicio del proceso. Orden de JSON y escala decimal equivalente no alteran hash; revisión esperada sí lo altera. +- A08: clave repetida con evento/datos/revisión distintos produce 409 IDEMPOTENCY_CONFLICT. Carrera simultánea con payloads distintos produce un 202 y un 409; no duplica ejecución ni revisión. +- A09: dos envíos idénticos simultáneos comparten ejecución; dos claves con igual revisión producen 202/412. PENDING/RUNNING/RETRY_WAIT/RECONCILIATION_REQUIRED impiden nuevo evento con revisión actual (409 REQUEST_BUSY); replay sigue permitido. +- Seguridad/validación: JWT ausente 401, owner/scope ajenos 404, rol ajeno 403, identidad/roles en comando rechazados, campos de proveedor fuera del schema 422. Guardia falsa GUARD_NOT_SATISFIED y valor ausente EVALUATION_ERROR, ambos 422 sin escrituras. If-Match ausente 428, inválido 400, obsoleto 412; payload excesivo 413. AcceptedEvent y Problem comprobados contra schemas. +- Atomicidad: fallos SQL reales inducidos al insertar ejecución, actualizar revisión e insertar auditoría revierten todo; auditoría previa de creación se conserva. Reintento tras fallo de auditoría funciona. Error SQL por HTTP retorna 503 seguro. Estado y data de negocio permanecen intactos al aceptar; accepted_revision es la nueva revisión (If-Match + 1). +- Flujo fijado RETIRED sigue aceptando eventos de instancias previas. Caso con INVOKE_INTEGRATION configurado acepta PENDING sin checkpoints, resultado ni actualización de datos: no existe adapter HTTP de proveedor invocado. Los cambios SQL a estados técnicos usados por pruebas son fixtures explícitos, no un worker implementado. +- Aislamiento: solo orquestador_test. Fixture compartido T07/T08 usa schema t07_b8dc4f7d95774fbc92a43745b161065d y LOGIN t07_runtime_b8dc4f7d95774fbc92a43745b161065d, creados/eliminados por cada suite propia. T03/T06 también limpian sus esquemas. No se tocó orquestador, no hubo despliegue ni uso de datos personales/proveedores. +- Corridas previas: 268 unitarias aprobadas; IT dirigida inicial 8 casos aprobados en 2:09 min. Se añadieron carrera con payload distinto/canonicalización y flujo con integración declarada; corrida final de 10 casos T09 y regresiones aprobada. No quedan fallos conocidos. +- JAR generado: target/orquestador-nmp-0.1.0-SNAPSHOT.jar; SHA256 552B5273EB904EA6ED447BD48B18C98C0E33ED97FFA0353A4CFD2ED5F1609F16. Inspección ZIP confirma ausencia de clases/controladores de prueba. git diff --check aprobado. +- Sin nuevas migraciones; usa V1/V2 y REQUEST_API_ENABLED existente con runtime restringido. ADR-013 documenta replay, accepted_revision y alcance. GET /executions/{id} queda como subtarea explícita T10; Location mantiene contrato objetivo. GET de solicitud ya muestra activeExecutionId, pero sin worker los eventos permanecen PENDING. A07–A09 de efectos externos requieren T10/T11/T16. +- Siguiente: T10 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. No se inició worker ni procesamiento externo. + +## E19 — Cierre T10 — 2026-10-27 08:52 (America/Mexico_City) + +- Criterios previos: reclamar con SKIP LOCKED, leases por tiempo DB, fencing en todas las escrituras, checkpoints/reuso y recuperación inicial; dos workers, lease vencido, retorno tardío, rollback final y GET de ejecución autorizado. No confundir executor local con cola durable ni declarar providers implementados. +- Archivos: WorkerState, WorkerStore/WorkerActions, PostgresWorkerStore, WorkerEngine/WorkerLoop/WorkerConfiguration, ExecutionController, PersistenceAccessConfiguration; V3__action_effect_checkpoint.sql, configuración application.yaml/compose/.env.example; WorkerIT y expectativas V3 de DatabaseMigrationIT; WORKER.md, ADR-014, contrato, ejemplos y documentación. +- Suite completa: `.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`; BUILD SUCCESS, 5:15 min, finalizado 2026-10-27T08:49:43-06:00. 268 unitarias + 91 IT (WorkerIT 15), cero fallos/errores/omisiones. Migraciones 40, eventos 10, publicación 11, creación 9, seguridad 6 y worker 15. +- Ajuste posterior acotado: fallar controladamente datos finales excesivos y validar tipo de capability; actualización contractual/documental. Se añadieron dos pruebas de worker (límite de datos y checkpoint faltante) y se verificó el código final con `.\mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external '-Dit.test=WorkerIT'`. BUILD SUCCESS, 2:36 min, finalizado 2026-10-27T08:52:38-06:00; 268 unitarias + 17 WorkerIT, sin fallos/errores/omisiones. No se repitieron IT ajenos al ajuste ya aprobados. Evidencia vigente: 361 casos distintos = 268 unitarias + 93 IT; no se presenta como una sola corrida final de 361. +- Entorno: JDK 21.0.12.1 y MIGRATION_TEST_DB_* solo en variables de proceso. Reportes target/surefire-reports y target/failsafe-reports; WorkerIT.txt refleja los 17 casos finales. +- Reclamación: dos stores/owners concurrentes reciben ejecuciones distintas; una fila bloqueada se omite; RETRY_WAIT futuro no se reclama hasta tiempo DB vencido. Renovación no revive lease vencido; recuperación incrementa fencing_token. Token antiguo no puede renovar, cargar, iniciar/guardar checkpoint, fallar ni confirmar. +- Procesamiento: ejecución sin acciones completa estado/datos/revisión/resultado/auditoría; decisión T05 aplica updates y conserva regla aplicada. Resultado del worker viejo que retorna después del nuevo no sobrescribe datos. Finalización rechaza snapshot/revisión obsoletos y checkpoints faltantes. Límites finales producen fallo controlado, no recuperación infinita. +- Checkpoints: caída simulada tras primer éxito conserva ese resultado, reintenta solo la acción incompleta con clave estable e input_hash compatible. Reconstrucción de repositorio/engine recupera desde PostgreSQL. Cambio de hash bloquea reuso. READ_ONLY/IDEMPOTENT_WRITE recuperables; NON_IDEMPOTENT_WRITE incierto no se repite y exige conciliación. Doble de proveedor idempotente registra dos llamadas pero un efecto por clave; esto no acredita un proveedor real. +- Atomicidad: fallo SQL inducido al auditar finalización revierte datos/estado/revisión/resultado finales; checkpoint de acción permanece SUCCEEDED y recuperación no repite llamada. Escritura exitosa seguida de conflicto final conserva solicitud bloqueada para conciliación. Adapter ausente falla explícitamente INTEGRATION_UNAVAILABLE, sin fabricar éxito. +- Scheduler real de prueba: reclama trabajo, renueva dos veces durante acción bloqueada por barrera, finaliza y se detiene; solicitud posterior queda PENDING. Heartbeat cancelado no interrumpe otro trabajo al reutilizar thread. Apagado drena con renovación hasta 20 s y deja trabajo restante para recuperación por lease. +- GET /executions/{id}: Pending/Succeeded/Failed contra schema ExecutionView, owner/scope ajenos 404, sin JWT 401. No se retornan snapshots de identidad, inputs, lease_owner o payload crudo de proveedor. Location T09 ya es consultable. +- Migración V3: persiste clasificación de efecto confiable; V1/V2 intactas, checkpoints previos conservadoramente NON_IDEMPOTENT_WRITE. Tres migraciones aplicadas/validadas e idempotentes; pruebas de permisos/rollback previas aprobadas con V3. +- Aislamiento: exclusivamente orquestador_test, fixtures temporales propios. Suite final WorkerIT: schema t07_b639d9ea7fe94294beead496e8b1d64b y rol t07_runtime_b639d9ea7fe94294beead496e8b1d64b eliminados por AfterAll. Suite completa usó t07_3a45246f4c704a54b855f3c40003427d más schemas propios T03/T06, también limpiados. No se tocó orquestador ni se desplegó. +- Límites explícitos: SimulatedCrash abandona intento y se reconstruye repositorio; no es reinicio de JVM. Adapters/proveedor deduplicador son dobles de test, no registros productivos. T11 implementará adapters reales/mocks HTTP; T12 política completa de errores transitorios/backoff/Retry-After/conciliación. T10 reconoce RETRY_WAIT y recuperación de lease, pero no anuncia retry HTTP completo. Docker y Keycloak reales no ejecutados en esta tarea. +- Defaults: WORKER_ENABLED=false, lease 60 s, renovación 15 s, concurrencia/lote 10, polling 500 ms. Puede correr sin API/admin, exige runtime DB restringido. Flujos sin acciones funcionan; integraciones no registradas fallan, por lo que se mantiene apagado para esos flujos hasta T11. +- Siguiente: T11 PENDING, dependencia DONE. Ninguna tarea principal IN_PROGRESS; no se inició integración de proveedor. + +## E20 — Cierre T11 — 2026-10-27 11:40 (America/Mexico_City) + +- Criterios previos: registro de handlers confiable, contratos/perfiles fijados, POST HTTP acotado, separación negocio/error, NO_OP solo simulación y referencias documentales verificadas. Pruebas HTTP reales contra mocks loopback y PostgreSQL real; sin proveedores ni despliegue. +- Archivos: HttpRoutes/BoundedHttp/HttpIntegrationHandler/IntegrationConfiguration, RegisteredWorkerActions/RegisterDocumentHandler/NoOpSimulationHandler, PublicationCatalogConfiguration, WorkerConfiguration y PostgresWorkerStore.save; config/integration-routes.json, application.yaml/compose/.env.example; HttpProviderFixture/HttpIntegrationTest/RegisterDocumentTest/HttpIntegrationIT, examples/integrations, INTEGRATIONS.md, WORKER.md, README y ADR-015. +- Prueba dirigida final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=HttpIntegrationIT -Dtest=HttpIntegrationTest,RegisterDocumentTest`; BUILD SUCCESS, 2:13 min, finalizado 2026-10-27T11:25:47-06:00. 33 tests + 6 IT, cero fallos/errores/omisiones. +- Correcciones durante prueba: el fixture decimal usaba Double y se sustituyó por parser decimal estricto; el JSON documental del fixture se corrigió a inputs.documentId conforme al DSL existente. Las pruebas finales dirigidas pasan; no se alteró el contrato para aceptar el fixture. +- Transporte: JSON duplicado/malformado/schema inválido rechazado; precisión decimal, mappings, headers de perfil/clave estable, bearer desde entorno, secreto ausente sin envío, versiones no fijadas/ajenas sin envío, 404 explícito, 429/5xx, rechazo de redirecciones, límite de memoria y timeout del cuerpo después de headers. Escritura incierta no se transforma en resultado de negocio. +- Negocio: cliente encontrado persiste datos y avanza START→END; cliente no encontrado SUCCEEDED/CLIENTE_NO_ENCONTRADO conserva START; timeout/503/contrato inválido FAILED sin customer ni outcome negativo. DOCUMENTO_VALIDA INVALID es resultado de negocio; BURO_CONSULTA exige score numérico. Son contratos propuestos con datos sintéticos, no integración real aprobada. +- Documentos: valida pertenencia a solicitud/ámbito, identidad, tipo, MIME, tamaño, checksum y scanStatus. Guarda referencia y checkpoint en una transacción con fencing. Trigger SQL inducido revierte ambos; recuperación READ_ONLY repite consulta y completa una única referencia. Servicio documental mock, sin subir/descargar archivos ni ejecutar antivirus local. +- Configuración: wiring Spring del registro comprobado con worker activo y administración apagada; catálogos vacíos predeterminados no autorizan endpoints. Ejemplos cargados por loaders reales; URLs .invalid, sin credenciales. Compose conserva un servicio, montaje config de solo lectura y PostgreSQL externo. No se requieren nuevas migraciones; V1/V2/V3 intactas. +- Alcance: A01/A02 y separación técnica de A03 cubiertos en fixtures; retries/backoff/Retry-After de A03 pendientes T12 y recorrido completo T14/T16. Integración real de clientes/documentos/buró requiere contratos/credenciales; no se contactó proveedor real, Keycloak ni runtime orquestador/server02:8787. Docker no ejecutado. +- Regresión completa final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; BUILD SUCCESS, 13:54 min, finalizado 2026-10-27T11:40:22-06:00. 301 pruebas Surefire + 99 Failsafe = 400; cero fallos/errores/omisiones. IT: migraciones 40, eventos 10, publicación 11, HTTP T11 6, solicitudes 9, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports. +- Aislamiento: credenciales MIGRATION_TEST_DB_* solo en entorno de proceso; exclusivamente orquestador_test. Schemas temporales t03/t06/t07 y roles runtime propios limpiados por AfterAll. La regresión usó t03_f14d09faa2f443e5929e85f05eec3e6d y fixture compartido t07_e677f961a04a4e83b6814d6f21c4a72b, recreado/limpiado por cada suite correspondiente. No se modificó la base runtime. +- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 2F9CC8EEB70FB7B966276BD98445B94732A9E70EEBB6424A105B18571646F0A0. Inspección ZIP confirma ausencia de mocks/clases de prueba; git diff --check aprobado. +- T11 DONE; siguiente T12 PENDING con dependencia DONE. Ninguna tarea principal IN_PROGRESS. Proveedores reales y despliegue siguen pendientes, no se presentan como pruebas aprobadas. + +## E21 — Cierre T12 — 2026-10-27 16:22 (America/Mexico_City) + +- Criterios previos: máximo publicado de tres intentos totales por acción; backoff/jitter/Retry-After acotados; RETRY_WAIT durable y atómico; conservar checkpoints y clasificar leases vencidos por efecto; no liberar UNKNOWN; resolve protegido, idempotente, con evidencia y sin elegir destino; fault injection y concurrencia con PostgreSQL real. +- Archivos: ActionResult/RetryDelay, WorkerStore/PostgresWorkerStore/WorkerEngine, HttpIntegrationHandler; Resolutions/ResolutionStore/PostgresResolutionStore/ResolutionConfiguration/ResolutionController y ApiExceptionHandler. Pruebas RetryPolicyTest/RetryReconciliationIT/CrashWorkerProcess y ampliaciones WorkerIT/HttpProviderFixture/HttpIntegrationIT; fixtures SecurityHttpIT/RequestCreationIT. Contrato OpenAPI, RECONCILIATION.md, ejemplos HTTP, README, WORKER/INTEGRATIONS, ADR-016 y seguimiento actualizados. +- Primera corrida dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=RetryReconciliationIT -Dtest=RetryPolicyTest,HttpIntegrationTest`; BUILD SUCCESS, 1:41 min, finalizado 2026-10-27T16:12:00-06:00. 25 unitarias + 8 IT, cero fallos/errores/omisiones. Se amplió después la cobertura de límites, concurrencia y fronteras de caída; incluida en la regresión final. +- Retry: estado de intento, próxima fecha con reloj DB y RETRY_SCHEDULED comparten transacción; fencing impide reencolar con token obsoleto. READ_ONLY/IDEMPOTENT_WRITE transitorios elegibles; claves/hash se conservan; éxitos previos no se repiten. Agotamiento sin efectos termina FAILED; escrituras inciertas requieren conciliación incluso tras agotar intentos. +- Resolve: requiere administración habilitada, EXECUTION_OPERATOR y ámbito permitido/coincidente; operador puede diferir del propietario. RESUME valida contrato/versiones/efecto y cobertura de acciones pendientes; CLOSE exige ausencia/compensación declarada con evidencia. Rechaza ausencia frente a escritura SUCCEEDED durable. No se inyectan referencias documentales ni estados destino. +- Atomicidad/replay: ledger append-only, checkpoints, cola y auditoría se confirman juntos. Mismo sujeto/comando/clave devuelve respuesta original tras completar; cambios de comando/sujeto confligen. Bloqueo solicitud→ejecución serializa resoluciones; fencing aumenta. La evidencia es declaración verificada por operador, no llamada automatizada al proveedor. +- Caídas: JVM hija exclusiva de prueba termina con Runtime.halt en fronteras anteriores/posteriores al HTTP y con checkpoint previo. El proceso padre reconstruye store/worker usando la DB durable; mock deduplica efecto lógico por clave. No es una prueba de reinicio de contenedor ni idempotencia de un proveedor real. +- Sin migraciones nuevas: V1/V2/V3 ya contienen estados/ledger/permisos. Compose permanece como un único servicio con PostgreSQL externo; flags por defecto sin cambios. Pruebas únicamente en orquestador_test, sin desplegar ni tocar runtime orquestador/server02:8787 ni proveedores reales. +- Regresión final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1 y credenciales MIGRATION_TEST_DB_* solo en el proceso. BUILD SUCCESS, 8:25 min, finalizado 2026-10-27T16:22:45-06:00. 303 unitarias Surefire + 116 IT Failsafe = 419, cero fallos/errores/omisiones. IT: migraciones 40, eventos 10, publicación 11, HTTP T11 6, solicitudes 9, RetryReconciliationIT 17, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports. +- Evidencia T12: Retry-After 429 real de mock programa 120 s, luego éxito con clave estable; tres fallos READ_ONLY agotan en FAILED sin outcome negativo; escritura idempotente agotada no libera solicitud. Éxitos previos no se repiten. Rollback inducido en auditoría de retry y de resolución conserva checkpoint/cola/ledger anteriores. Replay tras SUCCEEDED conserva respuesta, dos claves concurrentes producen 200/409 y una clave compartida produce una resolución. Datos/estado/revisión de negocio se conservan al cerrar. +- Cinco caídas reales de JVM: READ_ONLY antes de envío; IDEMPOTENT_WRITE después de HTTP y después de un checkpoint previo; NON_IDEMPOTENT_WRITE antes/después de HTTP. Los seguros recuperan; los no seguros exigen conciliación sin repetir llamada. Checkpoint previo conserva attempt_count=1; mock registra un efecto lógico por clave. WorkerIT confirma token obsoleto rechazado también al programar retry y mantiene pruebas de rollback final (A19). +- Aislamiento: esquemas temporales propios y roles LOGIN eliminados por AfterAll. Suite final utilizó t03_fa507cbe77934497a335700d620079c6 y fixture compartido t07_6687499ffd2b4582963ebdaccc2425c8 (recreado/limpiado por suites), además del schema propio T06. No se tocó runtime ni se desplegó; Docker y proveedores reales no ejecutados. +- Artefacto: target/orquestador-nmp-0.1.0-SNAPSHOT.jar, SHA256 7781CC0F17909D712E6FF34B262D2AEF52C34CF65955A2B938A7FF69A91ACC59. ZIP sin CrashWorkerProcess, mocks o clases de prueba. git diff --check aprobado. +- T12 DONE; siguiente T13 PENDING con dependencias DONE. Ninguna tarea principal IN_PROGRESS. A03 y A12–A14/A19 cubiertos localmente con los límites de proveedor mock indicados; T16 repetirá la matriz integral. + +## E22 — Cierre T13 — 2026-10-27 16:50 (America/Mexico_City) + +- Alcance confirmado con usuario: backend exclusivamente, sin desarrollar una interfaz gráfica. Criterios previos: consulta autorizada de eventos/schema/ui y bloqueos, versión fijada/retirada, guardias sin datos inventados, documentos ajenos sin vincular, INVALID conserva tarea/motivo, fallo técnico no produce INVALID y payload con resultados de proveedor rechazado (A11/A20). +- Archivos: AvailableEvents/View/Policy/AvailabilitySnapshot/AvailabilityStore/PostgresAvailabilityStore/AvailableEventsController y RequestConfiguration; document/domain/DocumentMetadata, RegisterDocumentHandler, WorkerEngine. AvailableEventPolicyTest/AvailableEventsIT/DocumentTasksIT, catálogo documental de ejemplo, OpenAPI, AVAILABLE_EVENTS.md, ejemplos HTTP, README, ADR-017 y seguimiento. +- Verificación dirigida final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=AvailableEventsIT,DocumentTasksIT -Dtest=AvailableEventPolicyTest,RegisterDocumentTest,DomainArchitectureTest`; BUILD SUCCESS, 2:46 min, finalizado 2026-10-27T16:40:04-06:00. 16 unitarias + 20 IT (8 disponibilidad y 12 documentos), cero fallos/errores/omisiones. Después se añadió el caso de referencia ya vinculada a otra solicitud; incluido en regresión completa. +- Correcciones de fixtures: publicación rechazó el flujo documental inicial. Se separó documentId de entrada de registeredDocumentId verificado y se declararon tipos string explícitos en enums status/scanStatus del catálogo de ejemplo para el análisis estático. No se relajaron las reglas de datos protegidos ni cambiaron valores aceptados del contrato. Un test puro comprueba la validez del fixture. +- Disponibilidad: usa una sentencia SQL para revisión/estado/definición fijada/ejecución activa coherentes; una escritura no confirmada no mezcla versiones de esos datos. Conserva ui anterior tras activar v2 y retirar v1. Filtra propietario/ámbito/roles; FLOW_ADMIN no accede a recurso ajeno. FORM y DOCUMENT_UPLOAD se ajustan al schema público. Los cuatro estados activos devuelven events vacío y blockedByExecutionId. +- Guardias: falso/error conocidos se proyectan como códigos genéricos; cualquier dependencia de event.data se difiere al POST. Prueba HTTP demuestra que un evento anunciado se rechaza con agree=false y se acepta con agree=true. No-store, sin ETag en esta ruta porque la disponibilidad puede cambiar sin revisión nueva; revision sigue disponible para If-Match. +- Documentos: verificador puro centraliza pertenencia/tipo/MIME/tamaño/checksum/CLEAN. Worker añade límites más restrictivos de la tarea publicada antes de insertar referencia y al reutilizar checkpoint; tamaño 1001 frente a tarea 1000 y MIME PNG permitido por catálogo pero no por tarea se rechazan. Conserva transacción/fencing de referencia+checkpoint T11. +- Negocio: recorrido real API/worker/HTTP mock/SQL INVALID deja SUCCEEDED/DOCUMENTO_INVALIDO con razón EXPIRED en datos y tarea visible; nuevo evento VALID avanza y oculta tarea sin duplicar referencia. Error 503 reintenta sin repetir metadata exitosa, bloquea tareas durante RETRY_WAIT y al agotar queda FAILED sin fabricar INVALID. Payloads con score/validation/MIME/scanStatus/identidad no crean ejecución. +- Límites: no se reciben archivos, no se calcula MIME/checksum desde bytes ni se ejecuta antivirus; se verifica el dictamen del servicio documental confiable. Solo mocks de prueba y datos sintéticos. Sin nuevas migraciones, despliegue o cambios al Compose de un servicio con PostgreSQL externo. No se toca runtime orquestador/server02:8787. +- Regresión completa final: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`, JDK 21.0.12.1; BUILD SUCCESS, 9:53 min, finalizado 2026-10-27T16:50:46-06:00. 308 unitarias Surefire + 137 IT Failsafe = 445; cero fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40, documentos 13, eventos 10, publicación 11, HTTP 6, solicitudes 9, retries/conciliación 17, seguridad 6 y worker 17. Reportes en target/surefire-reports y target/failsafe-reports. +- Caso adicional aprobado: una referencia ya vinculada a solicitud A no se reasigna a B aunque el mock entregue metadatos contradictorios; la referencia original permanece, B no obtiene vínculo, su checkpoint falla y no se invoca el validador posterior. +- Aislamiento: solo orquestador_test; credenciales MIGRATION_TEST_DB_* en entorno de proceso. Fixtures temporales t07_f89ebc09f5764cc397a280dff4436f93 (recreado/limpiado por suites), t03_0ba9b05d7ca24ad6839dd222fb795062 y schema propio T06; roles/schemas propios limpiados por AfterAll. Sin despliegue, Docker ni proveedor/antivirus real. +- Artefacto target/orquestador-nmp-0.1.0-SNAPSHOT.jar: SHA256 FD5B3A7718745754910BBB9A81C2338F0CEF7A4577590C5A2334C9DCFDB0AA8E. ZIP sin mocks, suites ni proceso de caída de prueba. git diff --check aprobado. +- T13 DONE; siguiente T14 PENDING con dependencias DONE. Ninguna tarea principal IN_PROGRESS. A11/A20 cubiertos con servicio documental mock; integración real pendiente de contrato/configuración. + +## E23 — Cierre T14 — 2026-10-28 (America/Mexico_City) + +- Criterios: tres JSON completos publicables, recorridos persistentes por API/worker/SQL, + crédito v1/v2 coexistente (A10), licencia por configuración (A18), compra y fronteras + 71/70/61/60. Solo backend, proveedores HTTP simulados y datos sintéticos. +- Archivos: examples/flows/*.json y README/seed; examples/integrations/catalog y rutas; + examples/http/demo-flows.http; DemoFlowTest/DemoFlowsIT; README y ADR-018. +- Validación inicial: DemoFlowTest, 3 casos aprobados (2026-10-27 23:51). +- Primera integración: 9 de 10 casos aprobados; la comprobación SQL usaba el nombre + flow_version_id en vez de flow_id. Corregido exclusivamente en test. +- Segunda integración: 8 de 10 casos aprobados; segunda licencia usaba otro UUID con + el mismo storageDocumentId sintético, infringiendo unicidad. Corregido el fixture + para identificar cada documento. Un caso quedó RETRY_WAIT; se amplió el plazo HTTP + del fixture demo de conexión 100 ms/respuesta 2 s a 1 s/5 s. Producción sin cambios. +- Hash agregado de src/main antes/después: 203974E7294C38BE32A19B44C51A6EDD371081B76A355A6688F59DDF32143B49. + Igualdad verificada: no cambios Java, recursos o migraciones de producción en T14. +- Pendiente resultado de verificación final: todas las unitarias + DemoFlowsIT, + DocumentTasksIT y HttpIntegrationIT. Se conserva E22 como regresión completa base; + no se atribuirá esa corrida anterior a T14. +- Alcance pendiente independiente: /admin/flows/{id}/simulate no implementado; revisar + en T16 antes de entrega T17 (ADR-018). La demo actual usa API operativa con mocks. +- DemoFlowsIT final: 10/10 aprobados, 166.8 s. Publicación/activación HTTP de los tres + archivos exactos; ambos créditos terminan después de activar v2, con flow_id distinto. + Rechaza buró prematuro en v2, conserva EXPIRED/LICENCIA_PENDIENTE y permite reemplazo + válido. Compra confirma/cancela sin invocar documentos/buró. Score ausente/textual + termina FAILED y conserva BURO_PENDIENTE; no produce REVISION_MANUAL. +- Surefire final: 311/311 aprobados. JAR sin suites/mocks/proceso de caída; SHA256 + FD5B3A7718745754910BBB9A81C2338F0CEF7A4577590C5A2334C9DCFDB0AA8E, + idéntico al artefacto T13. Pendiente únicamente finalizar regresión documental/HTTP. +- Verificación final completada: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dit.test=DemoFlowsIT,DocumentTasksIT,HttpIntegrationIT`. + BUILD SUCCESS, 5:53 min, finalizado 2026-10-28T01:14:02-06:00. 311 unitarias + + 29 IT = 340, cero fallos/errores/omisiones. IT: demo 10, documentos 13, HTTP 6. + Esta corrida sustituye los resultados fallidos de desarrollo descritos arriba. + No se repitieron las demás IT de E22: src/main y JAR permanecieron idénticos; + se verificó la regresión de consumidores del catálogo de ejemplo modificado. +- Reportes: target/surefire-reports, target/failsafe-reports y target/t14-final.log. + JDK 21 local. Solo orquestador_test; schema temporal t07_41df5db3c155418d99cbe30611c405aa + y rol propio limpiados por AfterAll (fixture recreado por suite). Credenciales + exclusivamente en entorno de proceso; no se tocó runtime ni se desplegó Docker. +- T14 DONE. T15 PENDING con dependencias DONE; ninguna tarea principal IN_PROGRESS. + Los pendientes históricos de los párrafos anteriores quedan resueltos por esta + corrida, salvo proveedores reales y endpoint simulate explícitamente indicados. + +## E24 — Cierre T15 — 2026-10-28 (America/Mexico_City) + +- Criterios previos: historial paginado/filtrado con ACL por recurso, auditoría + atómica, métricas sin cardinalidad por solicitud, logs sin PII, readiness/liveness + y apagado del worker. Única tarea principal IN_PROGRESS. Sin despliegue. +- Implementación: audit/api/HistoryController e infrastructure/HistoryStore; + QueueMetrics/ObservabilityConfiguration, CommandObservationFilter; seguridad de + Actuator; timers HTTP, logs/códigos/razones del worker y presupuesto de drenaje. + OBSERVABILITY.md, ADR-019, OpenAPI, ejemplo HTTP, README/RUNBOOK/SECURITY y env/Compose. +- Primera dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=ObservabilityTest,DomainArchitectureTest,HttpIntegrationTest -Dit.test=ObservabilityIT`. + BUILD SUCCESS, 1:54 min, finalizado 2026-10-28T01:47:22-06:00; 27 unitarias y + 15 IT, cero fallos/errores/omisiones. Apagado: latches prueban renovación durante + drenaje, interrupción por presupuesto y ausencia de nuevas reclamaciones. + Historial: empates de timestamp, filtros, binding de cursor, ACL, redacción y + triggers append-only; gauges SQL/DB-time y acceso a Actuator. Pool propio cerrado + como fallo controlado: readiness 503/liveness 200. No es caída de DB productiva. +- Se añadió después captura de logs con cliente y score centinela, y comprobación + durable de reasonCodes en DocumentTasksIT. Están incluidas en regresión final. +- Regresión completa en curso: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`. + 314 unitarias ya aprobadas; IT pendientes de terminar. No marcar DONE hasta + resultado final. Usa exclusivamente orquestador_test y esquemas/roles temporales; + secretos solo en entorno del proceso. Proveedores HTTP mock y datos sintéticos. +- Regresión completa: ObservabilityIT 16/16 aprobados, incluida captura de logs; + DocumentTasksIT 13/13 aprueba reasonCodes en auditoría, y DemoFlowsIT 10/10. +- Revisión final detectó binding ambiguo entre kind omitido y literal "null". + Se distingue ausencia con cadena vacía (inválida como kind) y se agrega aserción + HTTP específica. La corrida completa ya había compilado la versión previa; + al terminar se ejecutará nuevamente el caso de cursor y tests de operación/ + arquitectura contra el código final. No se atribuye ese ajuste a la corrida base. +- Regresión completa terminada: BUILD SUCCESS, 12:45 min, finalizado + 2026-10-28T02:01:05-06:00. Surefire 314 + Failsafe 163 = 477; cero + fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40, demo 10, + documentos 13, eventos 10, publicación 11, HTTP 6, observabilidad 16, + solicitudes 9, reintentos/conciliación 17, seguridad 6 y worker 17. +- WorkerIT conserva 17 casos aprobados: fallo inducido de inserción auditada hace + rollback de estado/revisión; recuperación reutiliza checkpoint exitoso, reglas + y actualización se confirman junto con auditoría, renovación y fencing vigentes. +- Reportes de la corrida completa conservados en target/t15-full-reports.zip antes + del check final dirigido del cursor. Log completo t15-final.log (ignorado por git). +- Check del código final: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=ObservabilityTest,DomainArchitectureTest -Dit.test=ObservabilityIT#historyAuthorizationAndCursorBindingNeverExposeForeignResource`. + BUILD SUCCESS, 59.678 s, finalizado 2026-10-28T02:02:47-06:00; 4 unitarias + + 1 IT, sin fallos/errores/omisiones. Cursor con kind omitido vs literal null + ya no comparte binding; también rechaza otro filtro y otra solicitud, y aplica ACL. +- Evidencia final: target/t15-cursor-final.log y reportes actuales; corrida completa + preservada en target/t15-full-reports.zip y t15-final.log. JAR final + target/orquestador-nmp-0.1.0-SNAPSHOT.jar SHA256 + B71C97EB0D16504069B326088F270F9546E4DD7379B876C4F05AF412B148370E. + ZIP sin suites/mocks/proceso de caída. git diff --check aprobado. +- Sin nuevas migraciones; historial/razones conservan transacción de negocio y + permisos append-only. Runtime orquestador/server02:8787 y proveedores reales + no usados. Compose sigue con un servicio y PostgreSQL externo; no se desplegó. +- T15 DONE. Los pendientes de verificación descritos arriba quedan resueltos por + las corridas finales. T16 PENDING con dependencias DONE, ninguna tarea IN_PROGRESS. + T16 conserva el pendiente explícito de simulate de ADR-018; Docker/integraciones + reales y política organizacional de retención no se declaran verificados aquí. + +## E25 — T16: implementación verificada, cierre bloqueado por Docker — 2026-10-28 (America/Mexico_City) + +- Criterios: matriz A01–A20 ejecutada sin skips, reinicios/concurrencia/rollback/ACL, + reportes conservados y CI reproducible con Docker; cerrar /simulate pendiente. + Única tarea principal IN_PROGRESS. T17 no iniciado, sin despliegue. +- Implementación: FlowSimulation/SimulationController; TransitionEvaluator extraído + mecánicamente de WorkerEngine para compartir proyección pura. Contrato y fixtures + administrativos, sin HTTP ni escrituras. NO_OP solo en simulación. Runtime sigue + con contratos fijados, idempotencia, checkpoints, leases y auditoría existentes. +- Pruebas nuevas: FlowSimulationTest (12 casos), SimulationIT (2), DemoFlowsIT + agrega API cliente 503 con agotamiento y replay antes/después de ejecutar con + una llamada al proveedor; cliente ausente comprueba outcome explícito. +- Matriz ejecutable specs/acceptance-cases.json y scripts/verify_acceptance.py. + Rechaza métodos ausentes, fallos/errores/skips; distingue external-postgresql de + testcontainers según XML real. 2 tests Python del checker aprobados. CI exige + docker info, clean verify y gate A01–A20; artefactos por 30 días. Maven falla si + no encuentra tests. No se afirma ejecución del workflow remoto. +- Verificación dirigida: `mvnw.cmd --batch-mode --no-transfer-progress verify -Pmigration-tests-external -Dtest=FlowSimulationTest,DomainArchitectureTest -Dit.test=SimulationIT`. + BUILD SUCCESS, 1:00 min, finalizado 2026-10-28T06:27:09-06:00. 13 unitarias + + 2 IT, cero fallos/errores/omisiones. El test de referencia externa se alineó al + rechazo estructural previo del DSL, sin relajar el filtro ni resolver la URL. +- Docker: Get-Command docker no encuentra ejecutable; no servicio Docker ni archivo + en instalación estándar; DOCKER_HOST/TLS/CERT_PATH/HOST_OVERRIDE sin configurar. + Se solicitó motor autorizado al usuario. Pendiente prueba directa Testcontainers. +- Regresión completa en curso con JDK 21 y perfil migration-tests-external, + exclusivamente orquestador_test. Hasta ahora 326 unitarias, 40 migraciones y + 12 recorridos demo aprobados. Proveedores/JWKS mock; secretos solo en proceso. +- Archivos operativos: SIMULATION.md, ACCEPTANCE.md, ADR-020, README, OpenAPI, + examples/http/simulate.http, .github/workflows/ci.yaml, pom.xml y seguimiento. +- Regresión completa externa: `mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external`. + BUILD SUCCESS, 18:22 min, finalizado 2026-10-28T06:48:10-06:00. 326 unitarias + + 167 IT = 493; cero fallos/errores/omisiones. IT: disponibilidad 8, migraciones 40, + demo 12, documentos 13, eventos 10, publicación 11, HTTP 6, observabilidad 16, + solicitudes 9, retry/conciliación 17, seguridad 6, simulación 2 y worker 17. +- `python scripts/verify_acceptance.py`: passed=true, environment=external-postgresql, + todos los métodos/parametrizaciones de A01–A20 ejecutados y aprobados. Dos tests + Python del checker también aprobados; incluye rechazo de un skip no mapeado. +- Puerta Docker con esos mismos reportes: `python scripts/verify_acceptance.py --require-testcontainers --output target/acceptance-docker-gate.json` devuelve passed=false + por entorno externo. Es el resultado esperado: no atribuir evidencia Testcontainers + a esta corrida. Reportes completos conservados ANTES de la prueba Docker en + target/t16-external-reports.zip; JSON target/acceptance-report.json y log t16-final.log. +- JAR comprobado: SHA256 FA50938E56D5ACCDE505E2FEEB4F76F1F9011BA01B90895084101350C9D16DAC. + Prueba directa de detección de Docker en curso con perfil predeterminado. +- Prueba Docker directa: `mvnw.cmd --batch-mode --no-transfer-progress verify -Dtest=FlowSimulationTest -Dit.test=SimulationIT` (sin perfil externo). + BUILD FAILURE, 9.795 s, finalizado 2026-10-28T06:49:37-06:00. Falla setup de + SimulationIT con `Could not find a valid Docker environment`; 1 error de entorno, + sin skips. No inicia PostgreSQL ni invoca runtime externo. La suite externa + anterior conserva su evidencia aprobada en ZIP/JSON. Los reportes XML actuales + contienen la prueba negativa de Docker y no deben confundirse con aquel archivo. +- Bloqueo externo confirmado, no defecto funcional ni prueba Docker aprobada. + T16 BLOCKED. No se marca DONE ni se inicia T17. Para cerrar: motor Docker local + o remoto autorizado; `mvnw.cmd --batch-mode --no-transfer-progress clean verify` + y `python scripts/verify_acceptance.py --require-testcontainers`, sin skips. +- Sin migraciones nuevas, cambios al Compose de un servicio, despliegue productivo + o llamadas a proveedores reales. Conservadas auditoría/idempotencia/versiones; + extracción del evaluador respaldada por regresión completa. git diff --check OK. diff --git a/specs/RECONCILIATION.md b/specs/RECONCILIATION.md new file mode 100644 index 0000000..347a21e --- /dev/null +++ b/specs/RECONCILIATION.md @@ -0,0 +1,77 @@ +# Reintentos y conciliación — T12 + +## Reintentos durables + +Los fallos TRANSIENT de READ_ONLY e IDEMPOTENT_WRITE se reencolan en RETRY_WAIT. +La programación, el fallo del intento y RETRY_SCHEDULED se confirman juntos, con +fencing y tiempo de PostgreSQL. No hay un sleep del worker ni una cola en memoria. +La solicitud permanece ocupada y conserva estado/datos de negocio. + +La política publicada limita a tres intentos totales por acción (incluido el +primero). Defaults: base exponencial 2 s/4 s, jitter entre 80 % y 120 %, limitado +por maxBackoff. Se respeta Retry-After numérico o fecha HTTP, acotado a 120 s; +valores inválidos se ignoran. El mayor de backoff con jitter y Retry-After define +el retraso. Los checkpoints SUCCEEDED no se repiten y la clave externa +executionId:actionId permanece estable. + +Al agotar intentos READ_ONLY sin escrituras pendientes se termina FAILED. +Escrituras inciertas, incluyendo una IDEMPOTENT_WRITE agotada, mantienen +RECONCILIATION_REQUIRED. Una operación NON_IDEMPOTENT_WRITE RUNNING al perder +lease jamás se repite automáticamente; incluso una caída antes del envío exige +conciliación si no existe prueba durable de ausencia de efecto. El límite de +intentos no puede liberar esa incertidumbre. + +## Resolución por operador + +POST /api/v1/admin/executions/{id}/resolve requiere: + +- FLOW_ADMIN_ENABLED=true y runtime DB restringido. +- JWT válido, rol EXECUTION_OPERATOR, ámbito en ADMIN_BUSINESS_SCOPES y coincidente + con la solicitud. El operador puede diferir de su propietario. +- Idempotency-Key obligatorio y comando ReconciliationCommand del contrato. +- Ejecución RECONCILIATION_REQUIRED para una resolución nueva. + +RESUME registra resultados verificados de acciones existentes y reencola PENDING. +La salida debe cumplir el contrato/perfil fijado y el efecto del checkpoint debe +coincidir con el catálogo confiable. No se permiten acciones inventadas, IDs +duplicados, incertidumbres pendientes ni sobrescribir un checkpoint exitoso con +otro resultado. Una acción ya exitosa puede incluirse únicamente con el mismo +resultado; no vuelve a ejecutarse. No se inyectan resultados manuales de +REGISTER_DOCUMENT: sus referencias deben provenir de la verificación documental. +La solicitud debe conservar estado y revisión aceptados. + +CLOSE_WITHOUT_TRANSITION exige ABSENT_VERIFIED o COMPENSATED_VERIFIED y cierra +FAILED sin transición, cambios de datos ni incremento de revisión. ABSENT_VERIFIED +se rechaza cuando existe una escritura SUCCEEDED durable; en ese caso corresponde +documentar compensación. La solicitud vuelve a admitir eventos. + +La evidencia exige reference y reason no vacíos. **La verificación la realiza el +operador fuera del servicio**: estos campos son una declaración autorizada y +auditada, no una consulta automática al proveedor. Usar una referencia a un caso +verificable; no adjuntar documentos personales, JWT o secretos. + +Resolución, checkpoints, cola y EXECUTION_RESOLVED comparten transacción. El ledger +es append-only. Idempotencia por executionId/clave; replay exige mismo comando +canónico y mismo sujeto operador, y conserva respuesta original incluso tras +completar la ejecución. Otro comando u operador con esa clave recibe 409. +Dos operadores con claves distintas no pueden aplicar simultáneamente dos +resoluciones nuevas. La resolución incrementa fencing_token. + +No existe campo para elegir un estado destino. El worker continúa aplicando la +definición publicada y confirma la transición de forma normal. + +## Pruebas + +RetryPolicyTest cubre límites y parsing. RetryReconciliationIT usa HTTP/JWT, +PostgreSQL real y triggers de fallos de auditoría. CrashWorkerProcess es una JVM +exclusiva de test que termina con Runtime.halt antes/después de llamar a un mock +HTTP: el proceso padre reconstruye el worker y recupera desde DB. El mock registra +un efecto lógico por clave; no acredita idempotencia de un proveedor real. + +WorkerIT conserva las pruebas de lease vencido, worker obsoleto, fallo después del +checkpoint y rollback de confirmación final. HttpIntegrationIT ahora espera +RETRY_WAIT en timeout/503 y FAILED en salida inválida. + +Ejecutar mvnw.cmd clean verify -Pmigration-tests-external con MIGRATION_TEST_DB_* +en el proceso y base orquestador_test. Sin ese perfil se requiere Docker para +Testcontainers. No se despliega ni se utiliza un proveedor real. diff --git a/specs/REQUESTS.md b/specs/REQUESTS.md new file mode 100644 index 0000000..e51b431 --- /dev/null +++ b/specs/REQUESTS.md @@ -0,0 +1,88 @@ +# Creación y consulta de solicitudes — T08 + +Implementados `POST /api/v1/requests` y `GET /api/v1/requests/{id}` según el contrato +OpenAPI. No se implementan todavía eventos, worker, historial ni tareas del front. + +## Habilitación + +`REQUEST_API_ENABLED=true` habilita ambas rutas y exige un runtime PostgreSQL +restringido con credenciales de migración separadas (ver SECURITY.md). Es +independiente de `FLOW_ADMIN_ENABLED`: se puede atender solicitudes sin exponer +administración ni cargar un catálogo de publicación local. Ambos interruptores +son false por defecto para conservar el despliegue previo con pgadmin hasta que +se configure la cuenta de ejecución correcta. No se modificaron cuentas remotas. + +El JWT debe contener sub, audiencia, firma y ámbito válidos. Cualquier actor así +autenticado puede crear una solicitud de un tipo habilitado; propietario y ámbito +se obtienen del token. Los roles de transición serán exigidos al aceptar eventos +en T09. No se introduce un rol creador nuevo ni una ACL de tipos inexistente. + +## Contrato + +POST recibe exclusivamente `typeKey` y `data`, con `Idempotency-Key` obligatorio +(1–200 caracteres ASCII visibles, sin espacios; una sola cabecera). Retorna 201, +Location y ETag fuerte. UUID, estado inicial, flujo, propietario, ámbito y revisión +son asignados por el servidor. Los datos deben satisfacer initialDataSchema y +dataSchema de la versión publicada; si falta initialDataSchema se usa dataSchema. +No se aplican defaults ni conversiones implícitas. Los schemas cerrados rechazados +en publicación protegen campos provenientes de proveedores. + +Límite de datos: 256 KiB de JSON canónico UTF-8; cuerpo: 256 KiB + 4096 bytes, +leído de forma acotada también sin Content-Length. Profundidad JSON máxima 64. +Se rechazan claves duplicadas, JSON adicional, Unicode incompatible con JSONB, +campos adicionales de comando y números fuera de los límites del codec T06. +Tamaño excesivo retorna 413; sintaxis/comando inválido 400; datos fuera del schema +422. Tipo inexistente: 404. Sin flujo publicado activo, deshabilitado o cambiado +concurrentemente durante la asignación: 409 INVALID_FLOW_STATE. Un comando fallido +no reserva su clave y puede reintentarse tras corregir la causa. + +GET retorna versión fijada, estado, datos, revisión, fechas y activeExecutionId +si existe ejecución PENDING/RUNNING/RETRY_WAIT/RECONCILIATION_REQUIRED. La lectura +SQL obtiene instancia/flujo/ejecución activa en un solo snapshot. Exige propietario +y ámbito, incluso para FLOW_ADMIN; inexistente y ajeno retornan 404. El DTO omite +owner/scope y los campos opcionales ausentes. La creación no copia la versión de +flujo a request_instance: la consulta la obtiene de flow_id. + +## Idempotencia y transacción + +Identidad de la clave: actor + ámbito + Idempotency-Key. Hash SHA-256 del comando +canónico typeKey/data usando el codec T06: orden de propiedades y escala decimal +equivalente no cambian el hash. Otra identidad tiene un espacio de claves distinto. + +El repositorio usa READ COMMITTED y la misma DataSource para RequestStore y +FlowStore. Antes de consultar idempotencia toma pg_advisory_xact_lock con 64 bits +de SHA-256 sobre schema/actor/ámbito/clave encuadrados por longitud. Una colisión +solo serializa claves distintas; la búsqueda y restricción única SQL siguen usando +la identidad completa. No hay locks de proceso ni caché de respuestas en memoria. + +Si existe registro y coincide el hash, se verifica acceso actual a la solicitud y +se devuelve el snapshot original de creación (201, Location y ETag originales), +aunque el flujo se haya retirado, el tipo esté deshabilitado o la solicitud haya +avanzado. GET obtiene el estado actual. Un hash distinto retorna 409 +IDEMPOTENCY_CONFLICT antes de resolver de nuevo el flujo activo. + +Sin registro previo, la puerta T06 bloquea flujo y tipo en ese orden; luego valida +datos, inserta solicitud con revisión 0, REQUEST_CREATED e idempotencia con respuesta +original. Todo confirma o revierte junto. La auditoría incluye correlationId y +referencias, sin duplicar datos personales. No se invocan servicios externos. +Un cambio de activo detectado al tomar los bloqueos produce conflicto reintentable; +no se asigna silenciosamente el flujo leído antes de la carrera. Un retiro espera +una creación que ya tiene sus locks; una vez confirmado impide nuevas asignaciones. + +## Verificación + +`mvnw.cmd clean verify -Pmigration-tests-external`, con MIGRATION_TEST_DB_* solo en +entorno, ejecuta la suite contra orquestador_test. RequestCreationIT reutiliza el +fixture aislado T07 (schema t07_UUID, LOGIN temporal y JWT RSA/JWKS local) con +administración apagada y API de solicitudes encendida. No se prueba una emisión +real de Keycloak ni se despliega el servicio en server02:8787. + +Casos: contrato RequestView/Problem, replay y conflicto, versiones fijadas, retiro, +separación de claves por actor/ámbito, acceso ajeno, validación/tamaño, concurrencia +misma clave y comandos distintos, activación y retiro con barreras, ejecución activa, +y rollback SQL real en auditoría/registro idempotente. RequestJsonTest verifica +parseo estricto, canonicalización decimal y límites UTF-8. + +Desde T09 se implementa también POST de eventos (EVENTS.md). La referencia anterior +al carácter pendiente de eventos describe el alcance de T08; worker, tareas e +historial siguen pendientes. REQUEST_API_ENABLED habilita ambos módulos. diff --git a/specs/RUNBOOK.md b/specs/RUNBOOK.md index dc83342..843653b 100644 --- a/specs/RUNBOOK.md +++ b/specs/RUNBOOK.md @@ -51,7 +51,7 @@ docker compose stop # Detener el servicio docker compose down # Eliminar contenedor y red de Compose ``` -Estos comandos no administran la base externa. Flyway permanece habilitado y aplicará las migraciones incluidas al arrancar; actualmente no existen migraciones SQL de negocio. La base debe existir antes del arranque. La separación de roles de migración/ejecución queda pendiente de T03/T07. +Estos comandos no administran la base externa. Flyway permanece habilitado y aplicará las migraciones incluidas al arrancar; se incluyen V1/V2 del modelo T03. La base debe existir antes del arranque. Las credenciales de migración pueden separarse según las instrucciones al final de este documento. ## Ejecución desde el IDE @@ -94,3 +94,94 @@ Son cinco pruebas de sólo lectura; resultados en `target/failsafe-reports`. El - Fallo de build: comprobar acceso a los repositorios de dependencias. Registrar evidencia en PROGRESS. No considerar verificado el arranque si no se ejecutó contra Docker y PostgreSQL externo. + +## Pruebas T03 sobre PostgreSQL externo + +Con JDK 21, sin Docker local, usar exclusivamente la base aislada creada para +pruebas. Las credenciales se suministran como variables de proceso: + +```powershell +$env:MIGRATION_TEST_DB_URL='jdbc:postgresql://server02:30432/orquestador_test' +$env:MIGRATION_TEST_DB_USER='pgadmin' +# Establecer MIGRATION_TEST_DB_PASSWORD mediante el entorno local. +.\mvnw.cmd --batch-mode --no-transfer-progress clean verify -Pmigration-tests-external +``` + +El perfil no utiliza DB_URL y rechaza cualquier base distinta de orquestador_test. +Crea un esquema t03_, ejecuta las migraciones y lo elimina al finalizar. +No toca otros esquemas. Crea un rol NOLOGIN temporal dentro de una transacción +revertida. Requiere permiso de crear esquemas y roles. Si el proceso se interrumpe +abruptamente, un administrador puede revisar el esquema temporal que haya quedado; +no borrar otros esquemas ni ejecutar Flyway clean. + +Sin ese perfil, verify requiere Docker y ejecuta la misma suite con Testcontainers +postgres:9.6.24. No hay fallback silencioso ni pruebas omitidas sin Docker. +El perfil deployment-smoke agrega comprobaciones HTTP; postgres96-check agrega +una comprobación de arranque de solo lectura, que exige migraciones ya aplicadas. +Ambos conservan los IT normales y pueden combinarse con migration-tests-external. + +## Esquema y credenciales de migración + +Flyway aplica V1/V2 al esquema orchestrator. DB_MIGRATION_USERNAME y +DB_MIGRATION_PASSWORD permiten separar migrador de usuario de aplicación; si se +omiten, se reutilizan las credenciales DB_USERNAME/DB_PASSWORD. Para aplicar +mínimo privilegio seguir [roles y migraciones](../src/main/resources/db/migration/README.md). + +El historial nuevo es orchestrator.flyway_schema_history. El arquetipo anterior +no incluía SQL de negocio. Si existe historial en public con migraciones reales, +detener la actualización y preparar su traslado explícito; no usar baseline ni +repair para ocultarlo. Esta implementación no despliega ni migra automáticamente +el servicio remoto: las migraciones se ejecutarán cuando se arranque el nuevo JAR. + +## Administración interna T06 + +El JAR incluye el validador y servicios internos de flujos, sin nuevas rutas +administrativas. Ver [publicación y catálogo](FLOW_PUBLICATION.md) para el alcance, +roles, canonicalización y orden de bloqueos. T07 incorporará el wiring y acceso +REST con identidad verificada; T06 no configura contratos reales de proveedores. + +El perfil migration-tests-external ejecuta tanto DatabaseMigrationIT como +FlowPublicationIT. Esta última crea y elimina únicamente su esquema t06_ +en orquestador_test, incluyendo un trigger de fallo de auditoría exclusivo de prueba. +La configuración de esquema normal del servicio sigue siendo orchestrator. + +T07: antes de habilitar FLOW_ADMIN_ENABLED revisar SECURITY.md. Un runtime pgadmin +será rechazado; provisionar rol restringido y separar las credenciales Flyway. +ADMIN_BUSINESS_SCOPES vacío deniega administración. JWT_BUSINESS_SCOPE_CLAIM debe +coincidir con el claim firmado por Keycloak; el valor por defecto es business_scope. + +T08: REQUEST_API_ENABLED=true habilita creación/consulta, independientemente de +FLOW_ADMIN_ENABLED. Se exige rol DB restringido también con administración apagada. +Provisionar y activar un tipo antes de crear; REQUESTS.md documenta respuestas, +reintentos y pruebas. Un replay POST trae revisión original; refrescar con GET para +obtener el estado/ETag actual antes de aceptar futuros eventos. + +T09: REQUEST_API_ENABLED habilita también POST de eventos. Conservar Idempotency-Key, +cuerpo e If-Match originales al reintentar un envío sin respuesta. GET de solicitud +muestra revisión/ejecución activa. Hasta implementar T10, PENDING no se procesa y GET +de ejecución no está disponible. Ver EVENTS.md; no invocar endpoints de proveedores. + +T10: aplicar V3 con usuario de migraciones antes de habilitar WORKER_ENABLED. +GET de ejecución se habilita con REQUEST_API_ENABLED. Revisar WORKER.md para +leases, checkpoints, apagado y recuperación. Dejar worker apagado en flujos con +integraciones hasta configurar adapters T11. Defaults 60 s lease / 15 s renovación; +los timeouts externos futuros deben ser menores y acotados. No hay retry HTTP T12 aún. + +## Seguimiento T15 + +Consultar /api/v1/requests/{id}/history como propietario para enlazar correlationId +con executionId, ruleIds y reasonCodes. Usar nextCursor con los mismos filtros; +from inclusivo/to exclusivo requieren UTC Z. Consultar OBSERVABILITY.md y +examples/http/history-operations.http. + +El operador autorizado consulta Actuator metrics. Vigilar la edad del pendiente, +leases expirados y RECONCILIATION_REQUIRED; no liberar ni reintentar ciegamente +escrituras inciertas. Si queue.sample.available=0, los gauges NaN son desconocidos, +no una cola vacía. No sumar estados DB de réplicas conectadas a la misma base. + +Al detener, Compose concede 40 s; el worker drena por WORKER_SHUTDOWN_SECONDS (1–20), +renueva los trabajos vigentes y luego interrumpe. Un trabajo incompleto conserva +lease/checkpoints: recuperar según T12. Readiness depende de DB; liveness no. +No cambiar el timeout de HTTP a uno mayor que el lease ni presumir cancelación +externa al interrumpir. Retención/borrado de auditoría requiere política aprobada +con procedimiento privilegiado separado, sin borrado automático por runtime. diff --git a/specs/SECURITY.md b/specs/SECURITY.md new file mode 100644 index 0000000..5e023e5 --- /dev/null +++ b/specs/SECURITY.md @@ -0,0 +1,109 @@ +# Seguridad T07 + +El servicio valida tokens RS256 mediante JWKS, emisor exacto, audiencia configurada, +expiración obligatoria y sujeto no vacío. También valida nbf cuando está presente; +el validador temporal de Spring admite 60 segundos de desfase. Las pruebas usan +tokens RSA realmente firmados y un servidor JWKS local. No acreditan todavía una +emisión real de Keycloak controlms: faltan un token de prueba y su configuración +de audiencia/claims, sin necesidad de entregar contraseñas al repositorio. + +`JWT_AUDIENCE` identifica la audiencia y el cliente cuyos roles se aceptan desde +`resource_access`. También se aceptan `realm_access.roles`. Roles de otros clientes +y el claim OAuth `scope` no conceden permisos técnicos. `JWT_BUSINESS_SCOPE_CLAIM` +selecciona el claim de negocio (por defecto `business_scope`); debe ser un string +no vacío. Identidad, roles y ámbito proceden exclusivamente del JWT verificado. + +## Administración + +`FLOW_ADMIN_ENABLED=false` mantiene deshabilitados los controladores administrativos. +Para habilitarlos se necesita un usuario DB de ejecución restringido, credenciales +de migración separadas, catálogo confiable y `ADMIN_BUSINESS_SCOPES` con ámbitos +permitidos separados por comas. Una lista vacía deniega todas las operaciones. +Los flujos y tipos son un catálogo global: pertenecer a uno de esos ámbitos autoriza +administrarlo según el rol; no implica aislamiento de flujos por tenant. + +| Operación implementada | Rol requerido | +|---|---| +| POST /api/v1/admin/flows; PUT /api/v1/admin/flows/{id} | FLOW_EDITOR | +| POST /api/v1/admin/flows/{id}/validate | FLOW_EDITOR o FLOW_PUBLISHER | +| POST /api/v1/admin/flows/{id}/publish | FLOW_PUBLISHER | +| POST /api/v1/admin/flows/{id}/retire | FLOW_ADMIN | +| PUT /api/v1/admin/request-types/{key}/active-flow | FLOW_ADMIN | + +Edición, publicación, retiro y activación requieren un ETag fuerte `If-Match`. +Ausente: 428; sintaxis inválida: 400; revisión obsoleta: 412. Editar un flujo publicado +retorna 409. JSON inválido/duplicado: 400; definición fuera de schema: 422; +cuerpo superior a 1 MiB: 413. Respuestas de error no exponen SQL ni tokens y llevan +un correlationId generado por el servidor. Sesiones y caché de peticiones deshabilitadas. + +`config/publication-catalog.json` es configuración de despliegue de solo lectura en +Docker. No puede reemplazarse desde HTTP. El ejemplo registra ANALISTA y ningún +handler, integración ni documento; no simula proveedores ni permite publicar +acciones no registradas. Reiniciar aplica una nueva configuración. Simulación y +resolución siguen pendientes de sus tareas funcionales; la matriz de roles no +constituye una implementación de esas rutas. + +## Recursos de negocio + +`ResourceAccess` exige propietario (`sub`) **y** ámbito coincidentes para solicitud, +ejecución, documento y auditoría. El adapter resuelve los tres últimos contra su +solicitud padre en PostgreSQL. Recurso inexistente o ajeno retorna el mismo 404; +FLOW_ADMIN no permite eludir esta política. La conciliación tiene una autorización +separada: EXECUTION_OPERATOR y mismo ámbito, aunque el propietario sea otro. + +T08 integra esta puerta en la consulta de solicitudes y filtra también propietario/ámbito +en el SELECT que devuelve los datos. PersistenceAccessConfiguration comparte codec, +FlowStore, ResourceAccess y la verificación DB cuando administración o solicitudes +estén habilitadas; REQUEST_API_ENABLED es independiente del interruptor administrativo. +T09/T12/T13/T15 deben reutilizar la puerta antes de devolver datos o ejecutar comandos. +Las pruebas T07 de ejecución/documento/auditoría usan un controlador exclusivo de test, +fuera del paquete escaneado y ausente del JAR; sus endpoints funcionales siguen pendientes. +T08 prueba POST/GET reales de solicitudes (REQUESTS.md). No se acepta identidad en cuerpos. + +## PostgreSQL externo + +Configurar `DB_USERNAME`/`DB_PASSWORD` para un rol LOGIN sin privilegios elevados, +sin propiedad ni CREATE en el schema orchestrator, sin pertenencia a roles elevados. +Configurar `DB_MIGRATION_USERNAME`/`DB_MIGRATION_PASSWORD` para el propietario que +aplica Flyway. El DBA provisiona ambos; la aplicación no crea roles automáticamente. +Después de las migraciones, el propietario concede los permisos de V2 mediante: + +```sql +SELECT orchestrator.grant_runtime_access('orchestrator_app'::name); +``` + +El nombre es ilustrativo: el rol debe existir previamente. `RuntimeRoleVerifier` +rechaza al iniciar administración un runtime elevado, propietario, con DDL en el +schema, capaz de modificar historiales append-only o acceder a flyway_schema_history. +Esta comprobación no configura permisos de otras bases/schemas: el DBA controla +también los permisos heredados de PUBLIC, particularmente en PostgreSQL 9.6. +No cambiar permisos globales del servidor para ejecutar las pruebas. + +Prueba reproducible: `mvnw.cmd clean verify -Pmigration-tests-external`, con +MIGRATION_TEST_DB_URL/USER/PASSWORD en entorno de proceso. Solo admite la base +orquestador_test. T07 crea schema t07_ y rol LOGIN t07_runtime_, contraseña +aleatoria y concesiones limitadas; los elimina en AfterAll. Las pruebas se conectan +con ese rol real, no con SET ROLE ni una simulación de permisos. Sin perfil externo, +verify requiere Docker y usa PostgreSQL de Testcontainers. + + +T09 integra ResourceAccess en aceptación de eventos y vuelve a filtrar propietario/ +ámbito bajo SELECT FOR UPDATE. allowedRoles del flujo se comprueba para eventos +nuevos. El replay requiere acceso actual y sujeto original, pero no vuelve a evaluar +roles/guardias: solo consulta una aceptación anterior. actor_snapshot guarda atributos +mínimos verificados; nunca JWT. Ver EVENTS.md. + +T10: GET de ejecución usa ResourceAccess y repite owner/scope en la consulta SQL; +no devuelve inputs, identidad snapshot, leases ni respuestas crudas. WORKER_ENABLED +habilita infraestructura común y RuntimeRoleVerifier incluso con ambas APIs apagadas. +El worker usa identidad mínima persistida al aceptar, no reutiliza JWT vencidos. + +## T15: historial y métricas + +El historial de solicitudes exige propietario y businessScope iguales al JWT; +FLOW_ADMIN/EXECUTION_OPERATOR no obtienen acceso a historial ajeno. La proyección +no entrega evidencia de conciliación ni resultados completos de proveedores. +/actuator/metrics y descendientes requieren EXECUTION_OPERATOR y ámbito autorizado +en ADMIN_BUSINESS_SCOPES, incluso con FLOW_ADMIN_ENABLED=false. Las tres rutas +health/liveness/readiness permanecen públicas sin detalles. Resto de /actuator/** +se deniega. Ver OBSERVABILITY.md para etiquetas permitidas, cursor y logs. diff --git a/specs/SIMULATION.md b/specs/SIMULATION.md new file mode 100644 index 0000000..740ff78 --- /dev/null +++ b/specs/SIMULATION.md @@ -0,0 +1,37 @@ +# Simulación administrativa + +POST /api/v1/admin/flows/{id}/simulate requiere FLOW_ADMIN_ENABLED, FLOW_EDITOR +y un businessScope permitido por ADMIN_BUSINESS_SCOPES. El actor del JSON es un +fixture para evaluar roles/guardias del flujo; no autentica al llamador. No acepta +un token dentro del JSON como autoridad. Flujos DRAFT/PUBLISHED/RETIRED pueden +evaluarse; nunca se cambia estado de publicación, checksum o revisión. + +Se lee una versión del flujo y se libera el bloqueo antes de evaluar. Body UTF-8 +máximo 1 MiB, sin claves duplicadas; requestData/event.data y cada resultado tienen +límite 256 KiB. Aplica SimulationInput/SimulationResult del contrato. Un error de +sintaxis es 400, tamaño 413, contrato/tipos/ambigüedad 422; rol de negocio del fixture +no permitido 403 y evento inexistente 409. Respuesta no-store. + +La simulación recorre una transición: valida datos de estado actual (dataSchema), +evento, roles/guardia y mappings de entradas; luego valida fixtures de resultados +contra contratos/perfiles del catálogo confiable. results usa exactamente los +resultKey de acciones externas seleccionadas; no se permiten resultados sobrantes +ni omitidos. NO_OP toma output de su definición y no necesita una entrada results. +Sigue prohibido publicar/ejecutar NO_OP en el worker. + +Los drafts usan versión de contrato/perfil indicada o default del catálogo. Las +publicadas usan sus versiones fijadas. No requiere rutas HTTP ni secretos; catálogo +de contratos sí. No resuelve referencias de schemas a red/archivos. Las guardias se +evalúan antes de resultados, como al aceptar un evento real. Las decisiones, updates, +validación final y reglas coincidentes usan TransitionEvaluator compartido con el +worker. UNIQUE ambiguo devuelve AMBIGUOUS_DECISION, no una ruta de negocio. + +No crea solicitudes, ejecuciones, checkpoints, referencias documentales, auditorías +ni eventos. No invoca proveedores ni ejecuta reintentos. Las respuestas documentales +son fixtures, no prueba de propiedad/antivirus de un documento real. La simulación +valida MIME/tamaño/tipo configurados, no verifica almacenamiento o consentimiento. +No equivale a publicar un draft válido ni a un recorrido persistente de demo. + +Ejemplo: [simulate.http](../examples/http/simulate.http). FlowSimulationTest cubre +fronteras, contrato, guardias, NO_OP y ambigüedad; SimulationIT comprueba JWT/ACL, +schemas HTTP, límites y que los conteos/revisión permanecen iguales tras simular. diff --git a/specs/WORKER.md b/specs/WORKER.md new file mode 100644 index 0000000..c94e7c9 --- /dev/null +++ b/specs/WORKER.md @@ -0,0 +1,121 @@ +# Worker durable y consulta de ejecución — T10 + +El worker usa PostgreSQL como cola durable. `WORKER_ENABLED=true` activa polling, +reclamación, renovación y recuperación; false por defecto. Es independiente de +REQUEST_API_ENABLED y FLOW_ADMIN_ENABLED, pero siempre exige el runtime DB +restringido y Flyway separado. Puede desplegarse como proceso solo worker usando +la misma imagen. No se ha desplegado esta versión. + +Defaults: 10 workers, lote máximo 10, polling 500 ms, lease 60 s y renovación cada +15 s. Variables WORKER_CONCURRENCY/BATCH/POLL_MS/LEASE_SECONDS/RENEW_SECONDS en +.env.example y compose.yaml. La renovación debe ser menor que la mitad del lease. +El scheduler reclama únicamente capacidad libre; no acumula una cola local de +trabajos ya reclamados que caduquen esperando un thread. Los executors locales +despachan trabajo; execution/action_execution conservan toda la evidencia durable. + +## Protocolo y concurrencia + +Un CTE con FOR UPDATE SKIP LOCKED reclama PENDING/RETRY_WAIT vencidos y RUNNING con +lease expirado. Orden next_attempt_at/created_at/id, límite de lote. En la misma +transacción incrementa attempts/fencing_token y fija owner/lease con clock_timestamp +de PostgreSQL. Se confirma antes de evaluar o invocar un adapter. + +Renovar requiere RUNNING, executionId/requestId, owner y token exactos, y lease aún +vigente. Un lease vencido no revive por renovación. Reclamarlo aumenta el token; +el propietario anterior no puede cargar, iniciar/guardar checkpoints, fallar ni +confirmar el trabajo. Escrituras de checkpoint bloquean la ejecución y verifican +lease con tiempo DB al inicio y antes del commit. Finalización/fallo bloquean +solicitud y después ejecución; no hay transacciones abiertas mientras corre el adapter. + +El worker carga estado/revisión aceptados y la definición por flow_id fijado. +Rechaza una solicitud modificada antes de llamar al adapter y vuelve a comprobar +estado, revisión, versión y lease al finalizar. accepted_revision es la revisión +posterior a aceptar T09. Un worker obsoleto que retorna después de otro no puede +sobrescribir el resultado, aunque el proveedor haya procesado su llamada. + +## Acciones y checkpoints + +WorkerActions es un registro de adapters confiable, consciente de versión, que +declara capability/schemas/effect y ejecuta la acción. ActionContext contiene inputs +resueltos, identidad mínima y clave externa estable `executionId:actionId`. +No se aceptan capacidades ni URLs del evento. NO_OP sigue prohibido fuera de simulación. + +Antes de invocar se valida input y persiste checkpoint RUNNING, input_hash SHA-256, +clave externa, clasificación de efecto e intento. Al retornar, valida output y lo +guarda SUCCEEDED antes de avanzar. Tras recuperación, SUCCEEDED se reutiliza solo +si hash/clave/effect coinciden; no vuelve a ejecutar el adapter. Los mappings de +acciones posteriores usan los resultados recuperados. Cambio incompatible bloquea +con error de contrato y conciliación; nunca se usa silenciosamente un resultado ajeno. + +V3 agrega action_execution.effect. Lo declara el adapter confiable, no el flujo. +Checkpoints anteriores a V3 se clasifican conservadoramente NON_IDEMPOTENT_WRITE; +no se inventa que eran seguros. V1/V2 no se alteran. Migración/rollback y permisos +se verifican contra PostgreSQL 9.6.2 en la base de pruebas. + +Recuperación inicial: + +- Acción READ_ONLY o IDEMPOTENT_WRITE en curso puede retomarse con misma clave y + hash, respetando maxAttempts del contrato de acción. +- NON_IDEMPOTENT_WRITE RUNNING o UNKNOWN no se repite: ejecución pasa a + RECONCILIATION_REQUIRED y la solicitud permanece bloqueada. +- Un fallo posterior a escrituras exitosas o pendientes tampoco libera la + solicitud como FAILED; se conserva para conciliación. +- Fallo de persistencia final deja RUNNING/checkpoints existentes para recuperación + por lease. Al reanudar no se repiten acciones ya SUCCEEDED. + +T12 programa reintentos transitorios seguros con backoff/jitter y Retry-After +acotados. El checkpoint del intento, RETRY_WAIT y auditoría se confirman juntos; +no se pierden éxitos anteriores. Agotamiento seguro termina FAILED; escrituras +inciertas conservan el bloqueo para resolución administrativa. RECONCILIATION.md +documenta límites, evidencia, replay y autorización. +Los adapters T11 limitan sus timeouts y presupuesto de ejecución por debajo +del lease y respetar la idempotencia externa; el token local no evita duplicados +en el proveedor. + +## Finalización + +Tras las acciones, el evaluador puro T05 decide destino/outcome. Los updates se +calculan desde el snapshot original y resultados de acciones, se aplican sobre una +copia y se valida dataSchema y límite de 256 KiB antes de confirmar. JSON Pointer +admite objetos y posiciones existentes de arrays; no crea índices dispersos ni +interpreta append `-`. Padres de objeto ausentes se crean como objetos y el schema +final decide su validez. Nunca se actualiza identidad/ACL desde estos mappings. + +Transacción final comprueba todos los checkpoints esperados SUCCEEDED y actualiza +data/estado/revisión de solicitud, resultado SUCCEEDED de execution y auditoría +TRANSITION_COMPLETED con transitionId, actionIds, hitPolicy y ruleIds. Cualquier +fallo revierte esos cambios juntos, conservando los checkpoints previos. + +## Consulta y operación + +GET /api/v1/executions/{id} ya implementado bajo REQUEST_API_ENABLED. Reutiliza +ResourceAccess y filtra owner/scope en SQL. Retorna ExecutionView con estado, +resultado de negocio permitido y código de error seguro; no devuelve inputs de +acciones, actor_snapshot, tokens, lease_owner ni respuesta cruda del proveedor. +Recurso ajeno/inexistente retorna 404. Location de T09 ya puede consultarse. + +Apagado: detiene reclamación, conserva renovaciones durante hasta 20 s de drenaje, +interrumpe trabajos restantes y deja de renovar. Checkpoints/leases permiten la +recuperación. Un heartbeat que termina tarde no interrumpe otro trabajo que reutilice +el thread. Los adapters deben tener tiempo de espera finito; la interrupción no +garantiza cancelar un efecto externo, por eso se conserva la clasificación durable. + +T11 incorpora adapters HTTP y verificación documental en el registro productivo. +Una acción sin contrato/ruta configurados falla con INTEGRATION_UNAVAILABLE. +Los catálogos predeterminados están vacíos. Configurar contratos y rutas antes de +habilitar integraciones; consultar INTEGRATIONS.md. NO_OP no puede ejecutarse. + +## Evidencia y límites + +WorkerIT usa PostgreSQL real/JWT local y adapters exclusivamente de prueba. Las +barreras verifican reclamantes concurrentes, SKIP LOCKED, fencing, retorno tardío, +renovación durante acción, checkpoints tras caída, clave externa estable, efecto +no seguro, rollback final, decisiones y autorización HTTP. SimulatedCrash abandona +el intento sin guardar resultado; se reconstruye el adapter de persistencia y se +recupera desde DB. No se anuncia una prueba de caída del proceso/JVM ni integración +real con proveedor. La idempotencia del proveedor se demuestra solo con un doble +explícito que deduplica por clave; T11/T16 comprobarán los contratos externos. + +Ejecutar `mvnw.cmd clean verify -Pmigration-tests-external` con credenciales solo en +MIGRATION_TEST_DB_* y base orquestador_test. Sin ese perfil, verify necesita Docker +para Testcontainers. Los esquemas y roles temporales propios se eliminan al finalizar. diff --git a/specs/acceptance-cases.json b/specs/acceptance-cases.json new file mode 100644 index 0000000..a65da3b --- /dev/null +++ b/specs/acceptance-cases.json @@ -0,0 +1,77 @@ +{ + "A01": [ + "DemoFlowsIT#creditBoundaries" + ], + "A02": [ + "DemoFlowsIT#missingCustomerRemainsCreatedAndCanBeRetried" + ], + "A03": [ + "DemoFlowsIT#customerApiFailureRetriesAndNeverBecomesCustomerNotFound" + ], + "A04": [ + "DemoFlowsIT#creditBoundaries", + "DecisionEvaluatorTest#a04ScoringUsesActualContractExampleAndExactDecimalBoundaries" + ], + "A05": [ + "DemoFlowsIT#invalidProviderScoreNeverDefaultsToManualReview" + ], + "A06": [ + "DecisionEvaluatorTest#uniqueRejectsMultipleMatchesEvenWithIdenticalOutputAndFirstPreservesDeclaredOrder", + "FlowSimulationTest#ambiguousDraftDecisionIsNotConvertedIntoDefault" + ], + "A07": [ + "DemoFlowsIT#repeatedEventHasOneExecutionAndOneProviderCallEvenAfterCompletion" + ], + "A08": [ + "EventAcceptanceIT#concurrentDifferentPayloadsConflictAndNumericCanonicalReplayIsStable" + ], + "A09": [ + "EventAcceptanceIT#concurrentIdenticalAndDifferentKeysHaveOneAcceptance" + ], + "A10": [ + "DemoFlowsIT#existingCreditKeepsV1WhileNewCreditRequiresLicense" + ], + "A11": [ + "DocumentTasksIT#invalidRetainsTaskAndReasonThenValidCompletesWithoutDuplicateReference", + "DocumentTasksIT#untrustedOrNoncompliantMetadataNeverCreatesReference", + "DocumentTasksIT#anExistingReferenceCannotBeReassignedEvenIfRemoteMetadataDisagrees" + ], + "A12": [ + "RetryReconciliationIT#realChildJvmCrashAtEffectBoundaryRecoversOrRequiresResolution", + "WorkerIT#durableCheckpointSurvivesAdapterRestartAndUsesStableExternalKey" + ], + "A13": [ + "RetryReconciliationIT#realChildJvmCrashAtEffectBoundaryRecoversOrRequiresResolution", + "WorkerIT#idempotentWriteReusesProviderKeyAfterLostCheckpoint" + ], + "A14": [ + "WorkerIT#twoClaimantsHaveDisjointJobsAndLockedRowsAreSkipped", + "WorkerIT#renewalAndReclaimFenceEveryStaleMutation", + "WorkerIT#oldWorkerReturningAfterNewWorkerCannotOverwriteResult" + ], + "A15": [ + "EventAcceptanceIT#ownerScopeAndTransitionRolesCannotBeForged", + "ObservabilityIT#historyAuthorizationAndCursorBindingNeverExposeForeignResource", + "SimulationIT#simulationIsAuthorizedReadOnlyAndConformsToContract" + ], + "A16": [ + "FlowPublicationIT#databaseAndApplicationBothPreventPublishedMutation", + "SecurityHttpIT#rolesScopeAndImmutablePublicationOverHttp" + ], + "A17": [ + "FlowPublicationIT#activationRetirementRaceNeverLeavesRetiredVersionActive", + "FlowPublicationIT#retirementBeforeAssignmentPreventsANewRequestEvenWhenTransactionsOverlap" + ], + "A18": [ + "DemoFlowsIT#existingCreditKeepsV1WhileNewCreditRequiresLicense", + "DemoFlowTest#exactExampleIsPublishable" + ], + "A19": [ + "WorkerIT#finalCommitFailureRollsBackAndRecoveryDoesNotRepeatSucceededAction", + "RetryReconciliationIT#retryAuditFailureRollsBackScheduleAndRecovers", + "RetryReconciliationIT#resolutionAuditFailureRollsBackCheckpointLedgerAndQueue" + ], + "A20": [ + "DocumentTasksIT#clientCannotSubmitProviderFieldsOrReplaceMetadata" + ] +} diff --git a/src/main/java/com/organizacion/orchestrator/action/application/port/ActionHandler.java b/src/main/java/com/organizacion/orchestrator/action/application/port/ActionHandler.java new file mode 100644 index 0000000..8eb7e49 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/application/port/ActionHandler.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.action.application.port; + +import com.organizacion.orchestrator.action.domain.*; + +public interface ActionHandler { + String type(); + ActionResult execute(ActionDefinition definition, ActionContext context); +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/ActionCapability.java b/src/main/java/com/organizacion/orchestrator/action/domain/ActionCapability.java new file mode 100644 index 0000000..8b073d9 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/ActionCapability.java @@ -0,0 +1,15 @@ +package com.organizacion.orchestrator.action.domain; + +import com.organizacion.orchestrator.flow.domain.InlineSchema; +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.Objects; + +/** Trusted deployment catalog entry. Never deserialized from an editable flow. */ +public record ActionCapability(String type, Effect effect, InlineSchema configurationSchema, + InlineSchema inputSchema, InlineSchema outputSchema) { + public enum Effect { READ_ONLY, IDEMPOTENT_WRITE, NON_IDEMPOTENT_WRITE } + public ActionCapability { + Checks.key(type); Objects.requireNonNull(effect); Objects.requireNonNull(configurationSchema); + Objects.requireNonNull(inputSchema); Objects.requireNonNull(outputSchema); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/ActionContext.java b/src/main/java/com/organizacion/orchestrator/action/domain/ActionContext.java new file mode 100644 index 0000000..60fb74c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/ActionContext.java @@ -0,0 +1,16 @@ +package com.organizacion.orchestrator.action.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.Objects; + +/** Inputs already resolved and validated by the application; stable external key survives retries. */ +public record ActionContext(ExecutionId executionId, RequestId requestId, Actor actor, + ObjectValue inputs, String externalIdempotencyKey, int attempt) { + public ActionContext { + Objects.requireNonNull(executionId); Objects.requireNonNull(requestId); + Objects.requireNonNull(actor); Objects.requireNonNull(inputs); + Checks.text(externalIdempotencyKey, 200); Checks.positive(attempt); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/ActionDefinition.java b/src/main/java/com/organizacion/orchestrator/action/domain/ActionDefinition.java new file mode 100644 index 0000000..c90111e --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/ActionDefinition.java @@ -0,0 +1,43 @@ +package com.organizacion.orchestrator.action.domain; + +import com.organizacion.orchestrator.decision.domain.ValueExpression; +import com.organizacion.orchestrator.shared.domain.Checks; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** Editable configuration deliberately excludes URLs, credentials and effect classification. */ +public sealed interface ActionDefinition { + String id(); + String resultKey(); + String type(); + + record Integration(String id, String resultKey, String integrationKey, + Optional contractVersion, Optional profile, Optional profileVersion, + Map inputs, RetryPolicy retry) implements ActionDefinition { + public Integration { + Checks.key(id); Checks.key(resultKey); Checks.key(integrationKey); + Objects.requireNonNull(contractVersion).ifPresent(v -> Checks.text(v, 100)); + Objects.requireNonNull(profile).ifPresent(Checks::key); + Objects.requireNonNull(profileVersion).ifPresent(v -> Checks.text(v, 100)); + if (profileVersion.isPresent() && profile.isEmpty()) throw new IllegalArgumentException("Profile version without profile"); + inputs = Map.copyOf(inputs); inputs.keySet().forEach(Checks::key); + Objects.requireNonNull(retry); + } + public String type() { return "INVOKE_INTEGRATION"; } + } + record RegisterDocument(String id, String resultKey, String documentType, + ValueExpression documentId, RetryPolicy retry) implements ActionDefinition { + public RegisterDocument { + Checks.key(id); Checks.key(resultKey); Checks.key(documentType); + Objects.requireNonNull(documentId); Objects.requireNonNull(retry); + } + public String type() { return "REGISTER_DOCUMENT"; } + } + /** Only simulation may execute this action. Publication must reject it. */ + record NoOp(String id, String resultKey, ObjectValue output) implements ActionDefinition { + public NoOp { Checks.key(id); Checks.key(resultKey); Objects.requireNonNull(output); } + public String type() { return "NO_OP"; } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/ActionResult.java b/src/main/java/com/organizacion/orchestrator/action/domain/ActionResult.java new file mode 100644 index 0000000..985dc2e --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/ActionResult.java @@ -0,0 +1,25 @@ +package com.organizacion.orchestrator.action.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.Objects; +import java.util.Optional; + +public sealed interface ActionResult { + /** Includes normalized negative business outcomes such as found=false or status=INVALID. */ + record Completed(ObjectValue output, Optional providerReference) implements ActionResult { + public Completed { + Objects.requireNonNull(output); + Objects.requireNonNull(providerReference).ifPresent(v -> Checks.text(v, 200)); + } + } + enum FailureKind { TRANSIENT, PERMANENT, UNKNOWN_EFFECT } + /** Retry eligibility also depends on trusted capability and checkpoints, not just kind. */ + record TechnicalFailure(ErrorCode code, FailureKind kind, Optional retryAfter) implements ActionResult { + public TechnicalFailure(ErrorCode code,FailureKind kind){this(code,kind,Optional.empty());} + public TechnicalFailure { + Objects.requireNonNull(code); Objects.requireNonNull(kind); Objects.requireNonNull(retryAfter); + retryAfter.ifPresent(d->{if(d.isNegative()||d.compareTo(java.time.Duration.ofSeconds(120))>0)throw new IllegalArgumentException("Invalid retry hint");}); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/RetryDelay.java b/src/main/java/com/organizacion/orchestrator/action/domain/RetryDelay.java new file mode 100644 index 0000000..4853257 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/RetryDelay.java @@ -0,0 +1,15 @@ +package com.organizacion.orchestrator.action.domain; + +import java.time.Duration; +import java.util.Optional; + +/** Bounded exponential delay. Caller supplies randomness, making boundary tests deterministic. */ +public final class RetryDelay { + private RetryDelay(){} + public static Duration after(RetryPolicy policy,int completedAttempts,Optional hint,double random){ + if(completedAttempts<1||completedAttempts>=policy.maxAttempts()||random<0||random>=1)throw new IllegalArgumentException("No eligible retry"); + long base=Math.min(policy.maxBackoff().toMillis(),policy.initialBackoff().toMillis()*(1L<<(completedAttempts-1))); + long jittered=Math.min(policy.maxBackoff().toMillis(),Math.max(100,(long)(base*(0.8+random*0.4)))); + return Duration.ofMillis(Math.min(120000,Math.max(jittered,hint.map(Duration::toMillis).orElse(0L)))); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/domain/RetryPolicy.java b/src/main/java/com/organizacion/orchestrator/action/domain/RetryPolicy.java new file mode 100644 index 0000000..50a63dd --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/domain/RetryPolicy.java @@ -0,0 +1,17 @@ +package com.organizacion.orchestrator.action.domain; + +import java.time.Duration; +import java.util.Objects; + +public record RetryPolicy(int maxAttempts, Duration initialBackoff, Duration maxBackoff) { + public static final RetryPolicy DEFAULT = new RetryPolicy(3, Duration.ofSeconds(2), Duration.ofSeconds(4)); + public RetryPolicy { + Objects.requireNonNull(initialBackoff); Objects.requireNonNull(maxBackoff); + if (maxAttempts < 1 || maxAttempts > 3 + || initialBackoff.compareTo(Duration.ofMillis(100)) < 0 + || initialBackoff.compareTo(Duration.ofSeconds(60)) > 0 + || maxBackoff.compareTo(initialBackoff) < 0 + || maxBackoff.compareTo(Duration.ofSeconds(120)) > 0) + throw new IllegalArgumentException("Invalid bounded retry policy"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/infrastructure/NoOpSimulationHandler.java b/src/main/java/com/organizacion/orchestrator/action/infrastructure/NoOpSimulationHandler.java new file mode 100644 index 0000000..6cf945b --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/infrastructure/NoOpSimulationHandler.java @@ -0,0 +1,14 @@ +package com.organizacion.orchestrator.action.infrastructure; + +import com.organizacion.orchestrator.action.application.port.ActionHandler; +import com.organizacion.orchestrator.action.domain.*; +import java.util.Optional; + +/** Explicit simulation-only utility. Never a Spring bean or member of the worker registry. */ +public final class NoOpSimulationHandler implements ActionHandler { + public String type(){return "NO_OP";} + public ActionResult execute(ActionDefinition definition,ActionContext context){ + if(!(definition instanceof ActionDefinition.NoOp noOp))throw new IllegalArgumentException("Simulation NO_OP required"); + return new ActionResult.Completed(noOp.output(),Optional.empty()); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisterDocumentHandler.java b/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisterDocumentHandler.java new file mode 100644 index 0000000..d759460 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisterDocumentHandler.java @@ -0,0 +1,36 @@ +package com.organizacion.orchestrator.action.infrastructure; + +import com.organizacion.orchestrator.action.application.port.ActionHandler; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.integration.infrastructure.HttpIntegrationHandler; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.*; + +/** Verifies metadata through fixed DOCUMENTS_METADATA/v1 protocol. The worker persists the reference under fencing. */ +public final class RegisterDocumentHandler implements ActionHandler { + private final PublicationCatalog catalog;private final HttpIntegrationHandler metadata; + public RegisterDocumentHandler(PublicationCatalog catalog,HttpIntegrationHandler metadata){this.catalog=catalog;this.metadata=metadata;} + public String type(){return "REGISTER_DOCUMENT";} + private ActionDefinition.Integration lookup(ActionDefinition.RegisterDocument d){return new ActionDefinition.Integration(d.id(),d.resultKey(),"DOCUMENTS_METADATA",Optional.of("1"),Optional.empty(),Optional.empty(),Map.of(),d.retry());} + public Optional capability(ActionDefinition.RegisterDocument definition){ + var capability=catalog.handlers().get(type());var lookup=metadata.capability(lookup(definition)); + if(capability==null||capability.effect()!=ActionCapability.Effect.READ_ONLY||!catalog.documents().containsKey(definition.documentType()) + ||lookup.isEmpty()||lookup.get().effect()!=ActionCapability.Effect.READ_ONLY)return Optional.empty();return Optional.of(capability); + } + public ActionResult execute(ActionDefinition definition,ActionContext context){ + if(!(definition instanceof ActionDefinition.RegisterDocument document)||capability(document).isEmpty())return fail(ErrorCode.INTEGRATION_UNAVAILABLE); + try{ + String id=((StringValue)context.inputs().fields().get("documentId")).value();UUID parsed=UUID.fromString(id); + var input=new ObjectValue(Map.of("documentId",new StringValue(parsed.toString()),"requestId",new StringValue(context.requestId().value().toString()),"businessScope",new StringValue(context.actor().businessScope()))); + var response=metadata.execute(lookup(document),new ActionContext(context.executionId(),context.requestId(),context.actor(),input,context.externalIdempotencyKey(),context.attempt())); + if(!(response instanceof ActionResult.Completed completed))return response; + var policy=catalog.documents().get(document.documentType()); + var verified=com.organizacion.orchestrator.document.domain.DocumentMetadata.verify(parsed,context.requestId().value(),context.actor().businessScope(),document.documentType(),policy.mimeTypes(),policy.maxBytes(),completed.output()); + return new ActionResult.Completed(verified,Optional.empty()); + }catch(DomainException e){return fail(e.code()); + }catch(RuntimeException e){return fail(ErrorCode.INTEGRATION_CONTRACT_ERROR);} + } + private static ActionResult fail(ErrorCode code){return new ActionResult.TechnicalFailure(code,ActionResult.FailureKind.PERMANENT);} +} diff --git a/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisteredWorkerActions.java b/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisteredWorkerActions.java new file mode 100644 index 0000000..132ec6a --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/action/infrastructure/RegisteredWorkerActions.java @@ -0,0 +1,22 @@ +package com.organizacion.orchestrator.action.infrastructure; + +import com.organizacion.orchestrator.execution.application.port.WorkerActions; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.integration.infrastructure.HttpIntegrationHandler; +import com.organizacion.orchestrator.shared.domain.ErrorCode; +import java.util.Optional; + +public final class RegisteredWorkerActions implements WorkerActions { + private final HttpIntegrationHandler integrations;private final RegisterDocumentHandler documents; + public RegisteredWorkerActions(HttpIntegrationHandler integrations,RegisterDocumentHandler documents){this.integrations=integrations;this.documents=documents;} + public Optional capability(ActionDefinition definition){return switch(definition){ + case ActionDefinition.Integration i->integrations.capability(i); + case ActionDefinition.RegisterDocument d->documents.capability(d); + case ActionDefinition.NoOp ignored->Optional.empty(); + };} + public ActionResult execute(ActionDefinition definition,ActionContext context){return switch(definition){ + case ActionDefinition.Integration i->integrations.execute(i,context); + case ActionDefinition.RegisterDocument d->documents.execute(d,context); + case ActionDefinition.NoOp ignored->new ActionResult.TechnicalFailure(ErrorCode.ACTION_FAILED,ActionResult.FailureKind.PERMANENT); + };} +} diff --git a/src/main/java/com/organizacion/orchestrator/audit/api/HistoryController.java b/src/main/java/com/organizacion/orchestrator/audit/api/HistoryController.java new file mode 100644 index 0000000..b3a36c4 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/audit/api/HistoryController.java @@ -0,0 +1,37 @@ +package com.organizacion.orchestrator.audit.api; + +import com.organizacion.orchestrator.audit.infrastructure.HistoryStore; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind; +import com.organizacion.orchestrator.shared.infrastructure.security.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import javax.sql.DataSource; +import java.time.Instant; +import java.util.UUID; + +@RestController +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class HistoryController { + private final HistoryStore store;private final ResourceAccess access;private final JwtActorMapper actors; + public HistoryController(DataSource source,FlowJsonCodec codec,ResourceAccess access,JwtActorMapper actors,RuntimeRoleVerifier verified, + @Value("${orchestrator.database.schema:orchestrator}")String schema){this.store=new HistoryStore(source,schema,codec);this.access=access;this.actors=actors;} + @GetMapping(value="/api/v1/requests/{id}/history",produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt, + @RequestParam(defaultValue="50")int limit,@RequestParam(required=false)String cursor, + @RequestParam(required=false)String kind,@RequestParam(required=false)UUID executionId, + @RequestParam(required=false)String from,@RequestParam(required=false)String to){ + access.require(actors.actor(jwt),Kind.REQUEST,id); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(store.page(id,limit,kind,executionId,instant(from),instant(to),cursor)); + } + private static Instant instant(String value){ + if(value==null)return null; + try{if(!value.endsWith("Z"))throw new IllegalArgumentException("UTC required");return Instant.parse(value);} + catch(java.time.DateTimeException e){throw new IllegalArgumentException("Invalid time");} + } +} diff --git a/src/main/java/com/organizacion/orchestrator/audit/infrastructure/HistoryStore.java b/src/main/java/com/organizacion/orchestrator/audit/infrastructure/HistoryStore.java new file mode 100644 index 0000000..4f0463f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/audit/infrastructure/HistoryStore.java @@ -0,0 +1,55 @@ +package com.organizacion.orchestrator.audit.infrastructure; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.*; +import java.nio.charset.StandardCharsets; +import java.security.*; + +/** Read-only keyset projection. Cursor is a position, never an authorization credential. */ +public final class HistoryStore { + private final JdbcTemplate jdbc;private final String s;private final FlowJsonCodec codec; + private static final Set DETAILS=Set.of("correlationId","transitionId","acceptedRevision","ruleIds","reasonCodes","actionIds","hitPolicy","code","actionId","delayMs","mode","resolutionId"); + public HistoryStore(DataSource source,String schema,FlowJsonCodec codec){ + if(!schema.matches("[a-z_][a-z0-9_]*"))throw new IllegalArgumentException("Invalid schema"); + jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(5);s="\""+schema+"\".";this.codec=codec; + } + public String page(UUID request,int limit,String kind,UUID execution,Instant from,Instant to,String cursor){ + if(limit<1||limit>100||kind!=null&&!kind.matches("[A-Za-z][A-Za-z0-9_.-]{0,99}")||from!=null&&to!=null&&!from.isBefore(to))throw new IllegalArgumentException("Invalid filters"); + String binding=digest(request+"|"+(kind==null?"":kind)+"|"+execution+"|"+from+"|"+to); + var args=new ArrayList();args.add(request); + var sql=new StringBuilder("SELECT * FROM "+s+"audit_entry WHERE request_id=?"); + if(kind!=null){sql.append(" AND kind=?");args.add(kind);} + if(execution!=null){sql.append(" AND execution_id=?");args.add(execution);} + if(from!=null){sql.append(" AND created_at>=?");args.add(Timestamp.from(from));} + if(to!=null){sql.append(" AND created_at2000)throw new IllegalArgumentException("Invalid cursor"); + String[] parts=new String(Base64.getUrlDecoder().decode(cursor),StandardCharsets.UTF_8).split("\\|",-1); + if(parts.length!=4||!parts[0].equals("1")||!parts[1].equals(binding))throw new IllegalArgumentException("Invalid cursor"); + try{sql.append(" AND (created_at,id)>(?,?)");args.add(Timestamp.from(Instant.parse(parts[2])));args.add(UUID.fromString(parts[3]));} + catch(java.time.DateTimeException e){throw new IllegalArgumentException("Invalid cursor");} + } + sql.append(" ORDER BY created_at,id LIMIT ?");args.add(limit+1); + var rows=jdbc.query(sql.toString(),(rs,n)->{ + var item=JsonNodeFactory.instance.objectNode().put("id",rs.getString("id")).put("requestId",request.toString()) + .put("kind",rs.getString("kind")).put("actorSubject",rs.getString("actor_subject")).put("flowId",rs.getString("flow_id")) + .put("createdAt",rs.getTimestamp("created_at").toInstant().toString()); + for(var field:Map.of("executionId","execution_id","fromState","from_state","toState","to_state").entrySet()) + if(rs.getString(field.getValue())!=null)item.put(field.getKey(),rs.getString(field.getValue())); + var detail=item.putObject("detail"); + try{var raw=new com.fasterxml.jackson.databind.ObjectMapper().readTree(rs.getString("detail"));DETAILS.forEach(k->{if(raw.has(k))detail.set(k,raw.get(k));});} + catch(java.io.IOException e){throw new IllegalStateException("Invalid stored audit",e);} + return item; + },args.toArray()); + var root=JsonNodeFactory.instance.objectNode();var items=root.putArray("items");rows.stream().limit(limit).forEach(items::add); + if(rows.size()>limit){var last=rows.get(limit-1);String position="1|"+binding+"|"+last.get("createdAt").asText()+"|"+last.get("id").asText(); + root.put("nextCursor",Base64.getUrlEncoder().withoutPadding().encodeToString(position.getBytes(StandardCharsets.UTF_8)));} + return codec.canonical(root); + } + private static String digest(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/application/PureDecisionEvaluator.java b/src/main/java/com/organizacion/orchestrator/decision/application/PureDecisionEvaluator.java new file mode 100644 index 0000000..8abfae7 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/application/PureDecisionEvaluator.java @@ -0,0 +1,36 @@ +package com.organizacion.orchestrator.decision.application; + +import com.organizacion.orchestrator.decision.application.port.DecisionEvaluator; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import java.util.Objects; + +/** Stateless and reusable across workers. No I/O, persistence or provider access. */ +public final class PureDecisionEvaluator implements DecisionEvaluator { + private final EvaluationLimits limits; + private final ConditionEvaluator conditions; + public PureDecisionEvaluator() { this(EvaluationLimits.DEFAULT); } + public PureDecisionEvaluator(EvaluationLimits limits) { + this.limits = Objects.requireNonNull(limits); + this.conditions = new ConditionEvaluator(limits); + } + @Override + public DecisionResult evaluate(DecisionDefinition definition, EvaluationContext context) { + Objects.requireNonNull(definition); Objects.requireNonNull(context); + try { + AstLimits.validate(definition, limits); + DecisionDefinition.Rule selected = null; + for (var rule : definition.rules()) { + if (!conditions.evaluate(rule.when(), context)) continue; + if (selected != null) return new DecisionResult.Failure(ErrorCode.AMBIGUOUS_DECISION); + selected = rule; + if (definition.hitPolicy() == DecisionDefinition.HitPolicy.FIRST) break; + } + return selected == null + ? new DecisionResult.Selected(definition.defaultOutput(), java.util.List.of()) + : new DecisionResult.Selected(selected.output(), java.util.List.of(selected.id())); + } catch (DomainException error) { + return new DecisionResult.Failure(error.code()); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/application/port/DecisionEvaluator.java b/src/main/java/com/organizacion/orchestrator/decision/application/port/DecisionEvaluator.java new file mode 100644 index 0000000..4eb7b79 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/application/port/DecisionEvaluator.java @@ -0,0 +1,7 @@ +package com.organizacion.orchestrator.decision.application.port; + +import com.organizacion.orchestrator.decision.domain.*; + +public interface DecisionEvaluator { + DecisionResult evaluate(DecisionDefinition definition, EvaluationContext context); +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/AstLimits.java b/src/main/java/com/organizacion/orchestrator/decision/domain/AstLimits.java new file mode 100644 index 0000000..27b81c2 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/AstLimits.java @@ -0,0 +1,48 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.DomainException; +import com.organizacion.orchestrator.shared.domain.ErrorCode; +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.List; + +/** Iterative preflight includes branches that runtime short-circuiting will not visit. */ +public final class AstLimits { + private AstLimits() {} + private record Node(Object value, int depth) {} + public static void validate(DecisionDefinition definition, EvaluationLimits limits) { + if (definition.rules().size() > limits.maxNodes()) fail(); + var ids = new HashSet(); + for (var rule : definition.rules()) if (!ids.add(rule.id())) fail(); + validate(definition.rules().stream().map(DecisionDefinition.Rule::when).toList(), limits); + } + public static void validate(Condition condition, EvaluationLimits limits) { + validate(List.of(condition), limits); + } + private static void validate(List conditions, EvaluationLimits limits) { + var pending = new ArrayDeque(); + if (conditions.size() > limits.maxNodes()) fail(); + conditions.forEach(c -> pending.add(new Node(c, 1))); + int count = 0; + while (!pending.isEmpty()) { + var node = pending.removeLast(); + if (++count > limits.maxNodes() || node.depth() > limits.maxDepth()) fail(); + int depth = node.depth() + 1; + switch (node.value()) { + case Condition.Compare c -> { + pending.add(new Node(c.left(), depth)); pending.add(new Node(c.right(), depth)); + } + case Condition.Exists e -> pending.add(new Node(e.value(), depth)); + case Condition.Not n -> pending.add(new Node(n.arg(), depth)); + case Condition.Logical l -> { + if (l.args().size() > limits.maxNodes()) fail(); + l.args().forEach(c -> pending.add(new Node(c, depth))); + } + case ValueExpression ignored -> { } + default -> throw new IllegalArgumentException("Unknown AST node"); + } + if (pending.size() + count > limits.maxNodes()) fail(); + } + } + private static void fail() { throw new DomainException(ErrorCode.EVALUATION_ERROR); } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/Condition.java b/src/main/java/com/organizacion/orchestrator/decision/domain/Condition.java new file mode 100644 index 0000000..9fc9848 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/Condition.java @@ -0,0 +1,25 @@ +package com.organizacion.orchestrator.decision.domain; + +import java.util.List; +import java.util.Objects; + +/** Shape-safe AST. Evaluation, type checking and operational limits belong to T05. */ +public sealed interface Condition { + enum Comparison { EQ, NEQ, GT, GTE, LT, LTE, IN } + enum Junction { AND, OR } + record Compare(Comparison op, ValueExpression left, ValueExpression right) implements Condition { + public Compare { Objects.requireNonNull(op); Objects.requireNonNull(left); Objects.requireNonNull(right); } + } + record Exists(ValueExpression.Path value) implements Condition { + public Exists { Objects.requireNonNull(value); } + } + record Logical(Junction op, List args) implements Condition { + public Logical { + Objects.requireNonNull(op); args = List.copyOf(args); + if (args.size() < 2) throw new IllegalArgumentException("Logical operator needs at least two arguments"); + } + } + record Not(Condition arg) implements Condition { + public Not { Objects.requireNonNull(arg); } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/ConditionEvaluator.java b/src/main/java/com/organizacion/orchestrator/decision/domain/ConditionEvaluator.java new file mode 100644 index 0000000..e2156d1 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/ConditionEvaluator.java @@ -0,0 +1,108 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.ArrayDeque; +import java.util.Objects; + +/** Pure evaluator. Invalid data raises a safe domain code, never a false business condition. */ +public final class ConditionEvaluator { + private final EvaluationLimits limits; + private final JsonPointerResolver pointers = new JsonPointerResolver(); + public ConditionEvaluator() { this(EvaluationLimits.DEFAULT); } + public ConditionEvaluator(EvaluationLimits limits) { this.limits = Objects.requireNonNull(limits); } + + public boolean evaluate(Condition condition, EvaluationContext context) { + Objects.requireNonNull(condition); Objects.requireNonNull(context); + AstLimits.validate(condition, limits); + return evaluateChecked(condition, context); + } + + private boolean evaluateChecked(Condition condition, EvaluationContext context) { + return switch (condition) { + case Condition.Exists e -> pointers.resolve(e.value(), context) instanceof Lookup.Present; + case Condition.Not n -> !evaluateChecked(n.arg(), context); + case Condition.Logical l -> { + boolean and = l.op() == Condition.Junction.AND; + boolean result = and; + for (var arg : l.args()) { + result = evaluateChecked(arg, context); + if (and ? !result : result) break; + } + yield result; + } + case Condition.Compare c -> compare(c.op(), value(c.left(), context), value(c.right(), context)); + }; + } + + private JsonValue value(ValueExpression expression, EvaluationContext context) { + if (expression instanceof ValueExpression.Literal literal) return literal.value(); + var resolved = pointers.resolve((ValueExpression.Path) expression, context); + if (resolved instanceof Lookup.Present present) return present.value(); + throw error(); + } + + private boolean compare(Condition.Comparison op, JsonValue left, JsonValue right) { + if (op == Condition.Comparison.IN) return contains(left, right); + if (op == Condition.Comparison.EQ || op == Condition.Comparison.NEQ) { + boolean equal = equal(left, right); + return op == Condition.Comparison.EQ ? equal : !equal; + } + if (!(left instanceof NumberValue a) || !(right instanceof NumberValue b)) throw error(); + int comparison = a.value().compareTo(b.value()); + return switch (op) { + case GT -> comparison > 0; + case GTE -> comparison >= 0; + case LT -> comparison < 0; + case LTE -> comparison <= 0; + default -> throw error(); + }; + } + + private boolean contains(JsonValue left, JsonValue right) { + if (!(right instanceof ArrayValue array) || left == NullValue.INSTANCE) throw error(); + if (array.values().size() > limits.maxNodes()) throw error(); + // Check the WHOLE list before membership: an early match cannot hide malformed input. + Class type = array.values().isEmpty() ? left.getClass() : array.values().getFirst().getClass(); + for (var item : array.values()) + if (item == NullValue.INSTANCE || item.getClass() != type) throw error(); + if (left.getClass() != type) throw error(); + for (var item : array.values()) if (equal(left, item)) return true; + return false; + } + + private record Pair(JsonValue left, JsonValue right) {} + private boolean equal(JsonValue left, JsonValue right) { + var pending = new ArrayDeque(); + pending.add(new Pair(left, right)); + int nodes = 0; + boolean same = true; + while (!pending.isEmpty()) { + if (++nodes > limits.maxNodes()) throw error(); + var pair = pending.removeLast(); + JsonValue a = pair.left(), b = pair.right(); + if (a == NullValue.INSTANCE || b == NullValue.INSTANCE) { same &= a == b; continue; } + if (a.getClass() != b.getClass()) throw error(); + switch (a) { + case NumberValue n -> same &= n.value().compareTo(((NumberValue) b).value()) == 0; + case StringValue s -> same &= s.value().equals(((StringValue) b).value()); + case BooleanValue v -> same &= v.value() == ((BooleanValue) b).value(); + case ArrayValue arr -> { + var other = ((ArrayValue) b).values(); + if (arr.values().size() != other.size()) { same = false; break; } + if (arr.values().size() + pending.size() + nodes > limits.maxNodes()) throw error(); + for (int i = 0; i < other.size(); i++) pending.add(new Pair(arr.values().get(i), other.get(i))); + } + case ObjectValue obj -> { + var other = ((ObjectValue) b).fields(); + if (!obj.fields().keySet().equals(other.keySet())) { same = false; break; } + if (obj.fields().size() + pending.size() + nodes > limits.maxNodes()) throw error(); + obj.fields().forEach((key, val) -> pending.add(new Pair(val, other.get(key)))); + } + case NullValue ignored -> throw error(); // handled above + } + } + return same; + } + private static DomainException error() { return new DomainException(ErrorCode.EVALUATION_ERROR); } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionDefinition.java b/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionDefinition.java new file mode 100644 index 0000000..a71bca8 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionDefinition.java @@ -0,0 +1,17 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.List; +import java.util.Objects; + +public record DecisionDefinition(String id, HitPolicy hitPolicy, List rules, String defaultOutput) { + public enum HitPolicy { UNIQUE, FIRST } + public DecisionDefinition { + Checks.key(id); Objects.requireNonNull(hitPolicy); Checks.key(defaultOutput); + rules = List.copyOf(rules); + if (rules.isEmpty()) throw new IllegalArgumentException("Decision requires rules"); + } + public record Rule(String id, Condition when, String output) { + public Rule { Checks.key(id); Objects.requireNonNull(when); Checks.key(output); } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionResult.java b/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionResult.java new file mode 100644 index 0000000..2072872 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/DecisionResult.java @@ -0,0 +1,21 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.Checks; +import com.organizacion.orchestrator.shared.domain.ErrorCode; +import java.util.List; + +public sealed interface DecisionResult { + record Selected(String output, List matchedRuleIds) implements DecisionResult { + public Selected { + Checks.key(output); matchedRuleIds = List.copyOf(matchedRuleIds); + matchedRuleIds.forEach(Checks::key); + } + public boolean usedDefault() { return matchedRuleIds.isEmpty(); } + } + record Failure(ErrorCode code) implements DecisionResult { + public Failure { + if (code != ErrorCode.EVALUATION_ERROR && code != ErrorCode.AMBIGUOUS_DECISION) + throw new IllegalArgumentException("Not a decision error"); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationContext.java b/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationContext.java new file mode 100644 index 0000000..7ef7c6f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationContext.java @@ -0,0 +1,13 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.Actor; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.Objects; + +/** Data snapshots only; no clock, repository or HTTP capability is exposed to decisions. */ +public record EvaluationContext(ObjectValue requestData, ObjectValue eventData, ObjectValue results, Actor actor) { + public EvaluationContext { + Objects.requireNonNull(requestData); Objects.requireNonNull(eventData); + Objects.requireNonNull(results); Objects.requireNonNull(actor); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationLimits.java b/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationLimits.java new file mode 100644 index 0000000..219e541 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/EvaluationLimits.java @@ -0,0 +1,10 @@ +package com.organizacion.orchestrator.decision.domain; + +/** Deployment limits may be tightened, never raised above the MVP operational ceilings. */ +public record EvaluationLimits(int maxDepth, int maxNodes) { + public static final EvaluationLimits DEFAULT = new EvaluationLimits(20, 1000); + public EvaluationLimits { + if (maxDepth < 1 || maxDepth > 20 || maxNodes < 1 || maxNodes > 1000) + throw new IllegalArgumentException("Evaluation limits outside operational bounds"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/IntervalOverlapAnalyzer.java b/src/main/java/com/organizacion/orchestrator/decision/domain/IntervalOverlapAnalyzer.java new file mode 100644 index 0000000..972e487 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/IntervalOverlapAnalyzer.java @@ -0,0 +1,82 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.JsonValue.NumberValue; +import java.math.BigDecimal; +import java.util.*; + +/** Sound overlap detection for numeric intervals on the SAME path, not a general SAT solver. */ +public final class IntervalOverlapAnalyzer { + public record Overlap(String firstRuleId, String secondRuleId, String path) {} + public record Analysis(List overlaps, List unsupportedRuleIds) { + public Analysis { overlaps = List.copyOf(overlaps); unsupportedRuleIds = List.copyOf(unsupportedRuleIds); } + } + private record Interval(String path, BigDecimal lower, boolean lowerClosed, BigDecimal upper, boolean upperClosed) { + boolean empty() { + if (lower == null || upper == null) return false; + int c = lower.compareTo(upper); + return c > 0 || (c == 0 && !(lowerClosed && upperClosed)); + } + Interval intersect(Interval other) { + BigDecimal lo = lower; boolean lc = lowerClosed; + if (lo == null || (other.lower != null && other.lower.compareTo(lo) > 0)) { + lo = other.lower; lc = other.lowerClosed; + } else if (other.lower != null && lo.compareTo(other.lower) == 0) lc &= other.lowerClosed; + BigDecimal hi = upper; boolean hc = upperClosed; + if (hi == null || (other.upper != null && other.upper.compareTo(hi) < 0)) { + hi = other.upper; hc = other.upperClosed; + } else if (other.upper != null && hi.compareTo(other.upper) == 0) hc &= other.upperClosed; + return new Interval(path, lo, lc, hi, hc); + } + } + public Analysis analyze(DecisionDefinition definition) { + AstLimits.validate(definition, EvaluationLimits.DEFAULT); + var intervals = new LinkedHashMap(); + var unsupported = new ArrayList(); + for (var rule : definition.rules()) { + var interval = interval(rule.when()); + if (interval == null) unsupported.add(rule.id()); else intervals.put(rule.id(), interval); + } + var overlaps = new ArrayList(); + var entries = new ArrayList<>(intervals.entrySet()); + for (int i = 0; i < entries.size(); i++) for (int j = i + 1; j < entries.size(); j++) { + var first = entries.get(i); var second = entries.get(j); + if (first.getValue().path.equals(second.getValue().path) + && !first.getValue().intersect(second.getValue()).empty()) + overlaps.add(new Overlap(first.getKey(), second.getKey(), first.getValue().path)); + } + return new Analysis(overlaps, unsupported); + } + private Interval interval(Condition condition) { + if (condition instanceof Condition.Logical logical && logical.op() == Condition.Junction.AND) { + Interval result = null; + for (var arg : logical.args()) { + var next = interval(arg); + if (next == null || (result != null && !next.path.equals(result.path))) return null; + result = result == null ? next : result.intersect(next); + } + return result; + } + if (!(condition instanceof Condition.Compare c)) return null; + Condition.Comparison op = c.op(); + ValueExpression left = c.left(), right = c.right(); + if (left instanceof ValueExpression.Literal && right instanceof ValueExpression.Path) { + var swap = left; left = right; right = swap; + op = switch (op) { + case GT -> Condition.Comparison.LT; case GTE -> Condition.Comparison.LTE; + case LT -> Condition.Comparison.GT; case LTE -> Condition.Comparison.GTE; + default -> op; + }; + } + if (!(left instanceof ValueExpression.Path path) || !(right instanceof ValueExpression.Literal literal) + || !(literal.value() instanceof NumberValue number)) return null; + var n = number.value(); + return switch (op) { + case EQ -> new Interval(path.pointer(), n, true, n, true); + case GT -> new Interval(path.pointer(), n, false, null, false); + case GTE -> new Interval(path.pointer(), n, true, null, false); + case LT -> new Interval(path.pointer(), null, false, n, false); + case LTE -> new Interval(path.pointer(), null, false, n, true); + default -> null; + }; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/JsonPointerResolver.java b/src/main/java/com/organizacion/orchestrator/decision/domain/JsonPointerResolver.java new file mode 100644 index 0000000..dbe84c7 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/JsonPointerResolver.java @@ -0,0 +1,47 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.JsonValue; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** RFC 6901 token unescaping, restricted to the four trusted context roots. */ +public final class JsonPointerResolver { + public Lookup resolve(ValueExpression.Path path, EvaluationContext context) { + Objects.requireNonNull(path); Objects.requireNonNull(context); + String[] tokens = path.pointer().substring(1).split("/", -1); + int start; + JsonValue current; + switch (tokens[0]) { + case "request" -> { current = context.requestData(); start = 2; } + case "event" -> { current = context.eventData(); start = 2; } + case "results" -> { current = context.results(); start = 1; } + case "actor" -> { + List roles = context.actor().roles().stream().sorted() + .map(r -> (JsonValue) new StringValue(r)).toList(); + current = new ObjectValue(Map.of( + "subject", new StringValue(context.actor().subject()), + "businessScope", new StringValue(context.actor().businessScope()), + "roles", new ArrayValue(roles))); + start = 1; + } + default -> throw new IllegalArgumentException("Unsupported root"); + } + for (int i = start; i < tokens.length; i++) { + String token = tokens[i].replace("~1", "/").replace("~0", "~"); + if (current instanceof ObjectValue object) { + current = object.fields().get(token); + if (current == null) return Lookup.Missing.INSTANCE; + } else if (current instanceof ArrayValue array) { + if (!token.matches("0|[1-9][0-9]*")) return Lookup.Missing.INSTANCE; + // Overflow and an out-of-bounds array index are unresolved paths, not coercions. + if (token.length() > 10) return Lookup.Missing.INSTANCE; + long index = Long.parseLong(token); + if (index >= array.values().size()) return Lookup.Missing.INSTANCE; + current = array.values().get((int) index); + } else return Lookup.Missing.INSTANCE; + } + return new Lookup.Present(current); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/decision/domain/ValueExpression.java b/src/main/java/com/organizacion/orchestrator/decision/domain/ValueExpression.java new file mode 100644 index 0000000..957d02b --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/decision/domain/ValueExpression.java @@ -0,0 +1,17 @@ +package com.organizacion.orchestrator.decision.domain; + +import com.organizacion.orchestrator.shared.domain.JsonValue; +import java.util.Objects; + +public sealed interface ValueExpression { + record Path(String pointer) implements ValueExpression { + public Path { + Objects.requireNonNull(pointer); + if (!pointer.matches("^/(?:request/data|event/data|results|actor)(?:/(?:[^~/]|~[01])*)*$")) + throw new IllegalArgumentException("Invalid context pointer"); + } + } + record Literal(JsonValue value) implements ValueExpression { + public Literal { Objects.requireNonNull(value); } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/document/domain/DocumentMetadata.java b/src/main/java/com/organizacion/orchestrator/document/domain/DocumentMetadata.java new file mode 100644 index 0000000..89ca505 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/document/domain/DocumentMetadata.java @@ -0,0 +1,29 @@ +package com.organizacion.orchestrator.document.domain; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.*; + +/** Pure verification of trusted documentary-service metadata; client payload never supplies these fields. */ +public final class DocumentMetadata { + private DocumentMetadata(){} + public static ObjectValue verify(UUID document,UUID request,String scope,String type,Set mimeTypes,long maximum,ObjectValue metadata){ + try{ + var fields=metadata.fields(); + if(!UUID.fromString(text(fields,"documentId")).equals(document)||!UUID.fromString(text(fields,"requestId")).equals(request) + ||!text(fields,"businessScope").equals(scope))throw new DomainException(ErrorCode.ACCESS_DENIED); + requireLimits(type,mimeTypes,maximum,metadata); + if(!text(fields,"checksum").matches("[a-fA-F0-9]{64}")||!text(fields,"scanStatus").equals("CLEAN"))throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + Checks.text(text(fields,"storageDocumentId"),200); + var output=new HashMap(); + for(String name:List.of("documentId","requestId","businessScope","documentType","storageDocumentId","mimeType","sizeBytes","checksum"))output.put(name,fields.get(name)); + return new ObjectValue(output); + }catch(DomainException e){throw e;}catch(RuntimeException e){throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);} + } + public static void requireLimits(String type,Set mimeTypes,long maximum,ObjectValue metadata){ + try{ + var fields=metadata.fields();long size=((NumberValue)fields.get("sizeBytes")).value().longValueExact(); + if(!type.equals(text(fields,"documentType"))||!mimeTypes.contains(text(fields,"mimeType"))||size<0||size>maximum)throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + }catch(DomainException e){throw e;}catch(RuntimeException e){throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR);} + } + private static String text(Map fields,String key){return ((StringValue)fields.get(key)).value();} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/api/EventController.java b/src/main/java/com/organizacion/orchestrator/execution/api/EventController.java new file mode 100644 index 0000000..928bf00 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/api/EventController.java @@ -0,0 +1,50 @@ +package com.organizacion.orchestrator.execution.api; + +import com.organizacion.orchestrator.execution.application.Events; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.api.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.*; +import java.util.UUID; + +@RestController +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class EventController { + private final Events events;private final JwtActorMapper actors;private final FlowJsonCodec codec;private final RequestJson json; + public EventController(Events events,JwtActorMapper actors,FlowJsonCodec codec){this.events=events;this.actors=actors;this.codec=codec;json=new RequestJson(codec);} + public record AcceptedEventView(UUID executionId,UUID requestId,String status){} + @PostMapping(value="/api/v1/requests/{id}/events",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity accept(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{ + String key=single(request,"Idempotency-Key",400,"INVALID_IDEMPOTENCY_KEY"); + if(!key.matches("[!-~]{1,200}"))throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY"); + String match=single(request,"If-Match",428,"PRECONDITION_REQUIRED");long revision; + if(!match.matches("\"(0|[1-9][0-9]*)\""))throw new ApiException(400,"INVALID_IF_MATCH"); + try{revision=Long.parseLong(match.substring(1,match.length()-1));}catch(NumberFormatException e){throw new ApiException(400,"INVALID_IF_MATCH");} + if(request.getContentLengthLong()>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + var bytes=request.getInputStream().readNBytes(RequestJson.MAX_BODY_BYTES+1); + if(bytes.length>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE");String body; + try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();} + catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");} + var n=json.parse(body); + if(n==null||!n.isObject()||n.size()!=2||!n.path("event").isTextual()||!n.path("data").isObject())throw new ApiException(400,"INVALID_REQUEST"); + var result=events.accept(new RequestId(id),n.get("event").textValue(),(ObjectValue)codec.value(n.get("data")),key,revision,actors.actor(jwt),CorrelationFilter.id(request)); + return ResponseEntity.accepted().location(URI.create("/api/v1/executions/"+result.executionId().value())) + .body(new AcceptedEventView(result.executionId().value(),result.requestId().value(),result.status().name())); + } + private static String single(HttpServletRequest request,String header,int missing,String code){ + var values=request.getHeaders(header);if(!values.hasMoreElements())throw new ApiException(missing,code); + String value=values.nextElement();if(values.hasMoreElements())throw new ApiException(400,"INVALID_REQUEST");return value; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/api/ExecutionController.java b/src/main/java/com/organizacion/orchestrator/execution/api/ExecutionController.java new file mode 100644 index 0000000..7aecb9f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/api/ExecutionController.java @@ -0,0 +1,46 @@ +package com.organizacion.orchestrator.execution.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.infrastructure.security.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import javax.sql.DataSource; +import java.time.Instant; +import java.util.*; + +/** Read projection only: never returns action inputs, provider payloads, lease credentials or identity snapshots. */ +@RestController +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class ExecutionController { + private final JdbcTemplate jdbc;private final ResourceAccess access;private final JwtActorMapper actors;private final RequestJson json;private final String s; + public ExecutionController(DataSource source,ResourceAccess access,JwtActorMapper actors,FlowJsonCodec codec,RuntimeRoleVerifier verified, + @Value("${orchestrator.database.schema:orchestrator}")String schema){ + if(!schema.matches("orchestrator|t(?:07|08|09|10)_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + this.jdbc=new JdbcTemplate(source);this.access=access;this.actors=actors;json=new RequestJson(codec);s="\""+schema+"\"."; + } + @JsonInclude(JsonInclude.Include.NON_NULL) + public record View(UUID id,UUID requestId,String event,String status,Map result,Map error,Instant createdAt,Instant updatedAt){} + @GetMapping(value="/api/v1/executions/{id}",produces="application/json") + public View get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + var actor=actors.actor(jwt);access.require(actor,Kind.EXECUTION,id); + var rows=jdbc.query("SELECT e.* FROM "+s+"execution e JOIN "+s+"request_instance r ON r.id=e.request_id WHERE e.id=? AND r.owner_subject=? AND r.business_scope=?",(r,i)->{ + Map result=null;Map error=null;String status=r.getString("status"); + if(status.equals("SUCCEEDED")){ + var node=json.parse(r.getString("result"));result=new LinkedHashMap<>(); + for(String name:List.of("outcome","fromState","toState"))result.put(name,node.path(name).asText()); + if(node.path("reasonCodes").isArray()){var reasons=new ArrayList();node.get("reasonCodes").forEach(n->{if(n.isTextual()&&n.asText().matches("[A-Za-z][A-Za-z0-9_.-]{0,99}")&&reasons.size()<100)reasons.add(n.asText());});result.put("reasonCodes",reasons);} + }else if(r.getString("error_code")!=null){String code;try{code=ErrorCode.valueOf(r.getString("error_code")).name();}catch(IllegalArgumentException e){code=ErrorCode.ACTION_FAILED.name();}error=Map.of("code",code);} + return new View(r.getObject("id",UUID.class),r.getObject("request_id",UUID.class),r.getString("event_key"),status,result,error,r.getTimestamp("created_at").toInstant(),r.getTimestamp("updated_at").toInstant()); + },id,actor.subject(),actor.businessScope()); + if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst(); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/api/ResolutionController.java b/src/main/java/com/organizacion/orchestrator/execution/api/ResolutionController.java new file mode 100644 index 0000000..a7d2e58 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/api/ResolutionController.java @@ -0,0 +1,36 @@ +package com.organizacion.orchestrator.execution.api; +import com.organizacion.orchestrator.execution.application.Resolutions; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.api.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.nio.*; +import java.nio.charset.*; +import java.util.UUID; + +@RestController +@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true") +public class ResolutionController { + private final Resolutions resolutions;private final JwtActorMapper actors;private final FlowJsonCodec codec;private final RequestJson json; + public ResolutionController(Resolutions resolutions,JwtActorMapper actors,FlowJsonCodec codec){this.resolutions=resolutions;this.actors=actors;this.codec=codec;json=new RequestJson(codec);} + @PostMapping(value="/api/v1/admin/executions/{id}/resolve",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity resolve(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws java.io.IOException{ + var keys=request.getHeaders("Idempotency-Key"); + if(!keys.hasMoreElements())throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY"); + String key=keys.nextElement();if(keys.hasMoreElements()||!key.matches("[!-~]{1,200}"))throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY"); + if(request.getContentLengthLong()>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + byte[] bytes=request.getInputStream().readNBytes(RequestJson.MAX_BODY_BYTES+1);if(bytes.length>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + String body;try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();} + catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");} + var n=json.parse(body);if(n==null||!n.isObject())throw new ApiException(400,"INVALID_REQUEST"); + var result=resolutions.resolve(id,key,(ObjectValue)codec.value(n),actors.actor(jwt),CorrelationFilter.id(request)); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(codec.canonical(codec.tree(result))); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/Events.java b/src/main/java/com/organizacion/orchestrator/execution/application/Events.java new file mode 100644 index 0000000..ca48653 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/Events.java @@ -0,0 +1,19 @@ +package com.organizacion.orchestrator.execution.application; + +import com.organizacion.orchestrator.execution.application.port.EventStore; +import com.organizacion.orchestrator.execution.domain.AcceptedEvent; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; + +public final class Events { + private final EventStore store;private final ResourceAccess access; + public Events(EventStore store,ResourceAccess access){this.store=store;this.access=access;} + public AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long revision,Actor actor,String correlation){ + access.require(actor,Kind.REQUEST,request.value());Checks.key(event);Checks.nonNegative(revision);Checks.text(correlation,200); + if(key==null||!key.matches("[!-~]{1,200}"))throw new IllegalArgumentException("Invalid idempotency key"); + return store.accept(request,event,data,key,revision,actor,correlation); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/Resolutions.java b/src/main/java/com/organizacion/orchestrator/execution/application/Resolutions.java new file mode 100644 index 0000000..4c7b9b5 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/Resolutions.java @@ -0,0 +1,16 @@ +package com.organizacion.orchestrator.execution.application; +import com.organizacion.orchestrator.execution.application.port.ResolutionStore; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.UUID; + +public final class Resolutions { + private final ResolutionStore store;private final ResourceAccess access; + public Resolutions(ResolutionStore store,ResourceAccess access){this.store=store;this.access=access;} + public ObjectValue resolve(UUID execution,String key,ObjectValue command,Actor actor,String correlationId){ + access.requireResolution(actor,execution); + if(key==null||!key.matches("[!-~]{1,200}"))throw new IllegalArgumentException("Invalid key"); + return store.resolve(execution,key,command,actor,correlationId); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/port/EventStore.java b/src/main/java/com/organizacion/orchestrator/execution/application/port/EventStore.java new file mode 100644 index 0000000..a77a282 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/port/EventStore.java @@ -0,0 +1,11 @@ +package com.organizacion.orchestrator.execution.application.port; + +import com.organizacion.orchestrator.execution.domain.AcceptedEvent; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; + +public interface EventStore { + /** Lock authorized request, replay first, then validate and atomically accept without invoking actions. */ + AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long expectedRevision,Actor actor,String correlation); +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/port/ResolutionStore.java b/src/main/java/com/organizacion/orchestrator/execution/application/port/ResolutionStore.java new file mode 100644 index 0000000..fe19da4 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/port/ResolutionStore.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.execution.application.port; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.UUID; + +public interface ResolutionStore { + ObjectValue resolve(UUID execution,String key,ObjectValue command,Actor actor,String correlationId); +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerActions.java b/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerActions.java new file mode 100644 index 0000000..fab9fc8 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerActions.java @@ -0,0 +1,10 @@ +package com.organizacion.orchestrator.execution.application.port; + +import com.organizacion.orchestrator.action.domain.*; +import java.util.Optional; + +/** Trusted version-aware adapter registry; implementations must bound calls below lease and honor external keys. */ +public interface WorkerActions { + Optional capability(ActionDefinition definition); + ActionResult execute(ActionDefinition definition,ActionContext context); +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerStore.java b/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerStore.java new file mode 100644 index 0000000..dabddb4 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/application/port/WorkerStore.java @@ -0,0 +1,20 @@ +package com.organizacion.orchestrator.execution.application.port; + +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.time.Duration; +import java.util.*; + +public interface WorkerStore { + List claim(String owner,int limit,Duration lease); + void renew(Token token,Duration lease); + void retry(Token token,String action,ErrorCode error,Duration delay); + Work load(Token token); + List checkpoints(Token token); + Checkpoint start(Token token,String action,String inputHash,String externalKey,Effect effect); + void save(Token token,String action,String inputHash,ObjectValue output,Optional providerReference); + void complete(Token token,Completion completion); + void fail(Token token,ErrorCode error,boolean uncertain); +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/AcceptedEvent.java b/src/main/java/com/organizacion/orchestrator/execution/domain/AcceptedEvent.java new file mode 100644 index 0000000..b3e2b67 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/AcceptedEvent.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.execution.domain; + +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import java.util.Objects; + +public record AcceptedEvent(ExecutionId executionId,RequestId requestId,ExecutionStatus status){ + public AcceptedEvent{Objects.requireNonNull(executionId);Objects.requireNonNull(requestId);Objects.requireNonNull(status);} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/ActionExecution.java b/src/main/java/com/organizacion/orchestrator/execution/domain/ActionExecution.java new file mode 100644 index 0000000..dca0dd9 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/ActionExecution.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator.execution.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +public record ActionExecution(ActionExecutionId id, ExecutionId executionId, String actionId, + Status status, int attemptCount, String externalIdempotencyKey, String inputHash, + Optional result, Optional providerReference, Optional error, + Optional startedAt, Optional finishedAt) { + public enum Status { PENDING, RUNNING, SUCCEEDED, FAILED, UNKNOWN } + public ActionExecution { + Objects.requireNonNull(id); Objects.requireNonNull(executionId); Checks.key(actionId); + Objects.requireNonNull(status); Checks.nonNegative(attemptCount); Checks.text(externalIdempotencyKey, 200); + if (inputHash == null || !inputHash.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid input hash"); + Objects.requireNonNull(result); Objects.requireNonNull(providerReference).ifPresent(v -> Checks.text(v, 200)); + Objects.requireNonNull(error); Objects.requireNonNull(startedAt); Objects.requireNonNull(finishedAt); + if (status == Status.SUCCEEDED && (result.isEmpty() || finishedAt.isEmpty())) + throw new IllegalArgumentException("Incomplete successful checkpoint"); + if (startedAt.isPresent() && finishedAt.isPresent() && finishedAt.get().isBefore(startedAt.get())) + throw new IllegalArgumentException("Checkpoint time order"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/EventPolicy.java b/src/main/java/com/organizacion/orchestrator/execution/domain/EventPolicy.java new file mode 100644 index 0000000..0f41e24 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/EventPolicy.java @@ -0,0 +1,20 @@ +package com.organizacion.orchestrator.execution.domain; + +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; + +/** Pure selection and authorization; input schema is validated by the adapter before evaluating the guard. */ +public final class EventPolicy { + public Transition transition(FlowDefinition flow,String state,String event,Actor actor){ + var transition=flow.transitions().stream().filter(t->t.from().equals(state)&&t.event().equals(event)).findFirst() + .orElseThrow(()->new DomainException(ErrorCode.INVALID_EVENT)); + if(transition.allowedRoles().stream().noneMatch(actor.roles()::contains))throw new DomainException(ErrorCode.ACCESS_DENIED); + return transition; + } + public void guard(Transition transition,ObjectValue request,ObjectValue event,Actor actor){ + if(transition.guard().isPresent()&&!new ConditionEvaluator().evaluate(transition.guard().get(), + new EvaluationContext(request,event,ObjectValue.empty(),actor)))throw new DomainException(ErrorCode.GUARD_NOT_SATISFIED); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/Execution.java b/src/main/java/com/organizacion/orchestrator/execution/domain/Execution.java new file mode 100644 index 0000000..012f8e9 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/Execution.java @@ -0,0 +1,33 @@ +package com.organizacion.orchestrator.execution.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +public record Execution(ExecutionId id, RequestId requestId, String idempotencyKey, String payloadHash, + Actor actor, String eventKey, ObjectValue eventData, String transitionId, String fromState, + long acceptedRevision, ExecutionStatus status, int attempts, Instant nextAttemptAt, + Optional lease, long fencingToken, Optional result, Optional error, + Instant createdAt, Instant updatedAt) { + public Execution { + Objects.requireNonNull(id); Objects.requireNonNull(requestId); Checks.text(idempotencyKey, 200); + if (payloadHash == null || !payloadHash.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid payload hash"); + Objects.requireNonNull(actor); Checks.key(eventKey); Objects.requireNonNull(eventData); + Checks.key(transitionId); Checks.key(fromState); Checks.nonNegative(acceptedRevision); + Objects.requireNonNull(status); Checks.nonNegative(attempts); Objects.requireNonNull(nextAttemptAt); + Objects.requireNonNull(lease); Checks.nonNegative(fencingToken); Objects.requireNonNull(result); + Objects.requireNonNull(error); Objects.requireNonNull(createdAt); Objects.requireNonNull(updatedAt); + if ((status == ExecutionStatus.RUNNING) != lease.isPresent()) throw new IllegalArgumentException("Inconsistent lease"); + if (status == ExecutionStatus.SUCCEEDED && result.isEmpty()) throw new IllegalArgumentException("Missing business result"); + } + public record Lease(String owner, Instant until) { + public Lease { Checks.text(owner, 200); Objects.requireNonNull(until); } + } + /** A negative outcome may keep the same state while execution succeeds technically. */ + public record BusinessResult(String outcome, String toState, ObjectValue data) { + public BusinessResult { Checks.key(outcome); Checks.key(toState); Objects.requireNonNull(data); } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/ExecutionStatus.java b/src/main/java/com/organizacion/orchestrator/execution/domain/ExecutionStatus.java new file mode 100644 index 0000000..789d93b --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/ExecutionStatus.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.execution.domain; + +public enum ExecutionStatus { + PENDING, RUNNING, RETRY_WAIT, SUCCEEDED, FAILED, RECONCILIATION_REQUIRED; + public boolean active() { + return this == PENDING || this == RUNNING || this == RETRY_WAIT || this == RECONCILIATION_REQUIRED; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/domain/WorkerState.java b/src/main/java/com/organizacion/orchestrator/execution/domain/WorkerState.java new file mode 100644 index 0000000..6ba325c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/domain/WorkerState.java @@ -0,0 +1,16 @@ +package com.organizacion.orchestrator.execution.domain; + +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.*; + +public final class WorkerState { + private WorkerState(){} + public record Token(ExecutionId executionId,RequestId requestId,String owner,long fence){} + public record Work(Token token,FlowId flowId,FlowDefinition flow,Transition transition,ObjectValue data,ObjectValue event,Actor actor,long revision){} + public record Checkpoint(String actionId,String status,String inputHash,String externalKey,Effect effect,int attempts,Optional output){} + public record Completion(FlowId flowId,String toState,ObjectValue data,ObjectValue result,ObjectValue audit){} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/EventConfiguration.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/EventConfiguration.java new file mode 100644 index 0000000..377413b --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/EventConfiguration.java @@ -0,0 +1,18 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.application.Events; +import com.organizacion.orchestrator.execution.application.port.EventStore; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class EventConfiguration { + @Bean EventStore eventStore(DataSource source,FlowJsonCodec codec,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresEventStore(source,codec,schema);} + @Bean Events events(EventStore store,ResourceAccess access){return new Events(store,access);} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresEventStore.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresEventStore.java new file mode 100644 index 0000000..2659a24 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresEventStore.java @@ -0,0 +1,74 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.*; +import com.organizacion.orchestrator.execution.application.port.EventStore; +import com.organizacion.orchestrator.execution.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; + +public final class PostgresEventStore implements EventStore { + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String schema; + private final FlowJsonCodec codec;private final RequestJson json;private final EventPolicy policy=new EventPolicy(); + private static final ObjectMapper JSON=new ObjectMapper(); + public PostgresEventStore(DataSource source,FlowJsonCodec codec,String schema){ + if(!schema.matches("orchestrator|t0[789]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + this.schema="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15); + var manager=new DataSourceTransactionManager(source);manager.setValidateExistingTransaction(true);tx=new TransactionTemplate(manager); + tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);tx.setTimeout(30); + } + public AcceptedEvent accept(RequestId request,String event,ObjectValue data,String key,long expected,Actor actor,String correlation){ + String encoded=json.data(data);var command=JSON.createObjectNode().put("event",event).put("expectedRevision",expected); + command.set("data",codec.tree(data));String hash; + try{hash=HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(codec.canonical(command).getBytes(StandardCharsets.UTF_8)));} + catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);} + return tx.execute(status->{ + // Recheck ownership under the row lock; never rely solely on the preflight resource check. + var rows=jdbc.query("SELECT flow_id,current_state,data::text,revision FROM "+schema+"request_instance WHERE id=? AND owner_subject=? AND business_scope=? FOR UPDATE", + (r,i)->new Locked(r.getObject(1,UUID.class),r.getString(2),(ObjectValue)codec.value(json.parse(r.getString(3))),r.getLong(4)), + request.value(),actor.subject(),actor.businessScope()); + if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);var current=rows.getFirst(); + var replay=jdbc.query("SELECT id,payload_hash,actor_subject,status FROM "+schema+"execution WHERE request_id=? AND idempotency_key=?", + (r,i)->new Replay(r.getObject(1,UUID.class),r.getString(2),r.getString(3),ExecutionStatus.valueOf(r.getString(4))),request.value(),key); + if(!replay.isEmpty()){ + var previous=replay.getFirst();if(!previous.actor.equals(actor.subject()))throw new DomainException(ErrorCode.ACCESS_DENIED); + if(!previous.hash.equals(hash))throw new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT); + return new AcceptedEvent(new ExecutionId(previous.id),request,previous.status); + } + if(current.revision!=expected)throw new DomainException(ErrorCode.REVISION_CONFLICT); + if(Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+schema+"execution WHERE request_id=? AND status IN ('PENDING','RUNNING','RETRY_WAIT','RECONCILIATION_REQUIRED'))",Boolean.class,request.value()))) + throw new DomainException(ErrorCode.REQUEST_BUSY); + if(expected==Long.MAX_VALUE)throw new DomainException(ErrorCode.INVALID_EVENT); + var definitions=jdbc.query("SELECT definition::text FROM "+schema+"flow_definition WHERE id=? AND status IN ('PUBLISHED','RETIRED')",(r,i)->codec.decode(r.getString(1)),current.flow); + if(definitions.isEmpty())throw new DomainException(ErrorCode.INVALID_FLOW);var flow=definitions.getFirst(); + var transition=policy.transition(flow,current.state,event,actor); + var input=flow.events().stream().filter(e->e.key().equals(event)).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_EVENT)).inputSchema(); + var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(input.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + policy.guard(transition,current.data,data,actor); + UUID id=UUID.randomUUID();long acceptedRevision=expected+1; + var identity=JSON.createObjectNode().put("subject",actor.subject()).put("businessScope",actor.businessScope()); + var roles=identity.putArray("roles");actor.roles().stream().sorted().forEach(roles::add); + jdbc.update("INSERT INTO "+schema+"execution(id,request_id,idempotency_key,payload_hash,actor_subject,actor_snapshot,event_key,event_data,transition_id,from_state,accepted_revision,status) VALUES (?,?,?,?,?,?::jsonb,?,?::jsonb,?,?,?,'PENDING')", + id,request.value(),key,hash,actor.subject(),codec.canonical(identity),event,encoded,transition.id(),current.state,acceptedRevision); + jdbc.update("UPDATE "+schema+"request_instance SET revision=?,updated_at=now() WHERE id=?",acceptedRevision,request.value()); + var detail=JSON.createObjectNode().put("correlationId",correlation).put("transitionId",transition.id()).put("acceptedRevision",acceptedRevision); + jdbc.update("INSERT INTO "+schema+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,detail) VALUES (?,?,?,'EVENT_ACCEPTED',?,?,?,?::jsonb)", + UUID.randomUUID(),request.value(),id,actor.subject(),current.flow,current.state,codec.canonical(detail)); + return new AcceptedEvent(new ExecutionId(id),request,ExecutionStatus.PENDING); + }); + } + private record Locked(UUID flow,String state,ObjectValue data,long revision){} + private record Replay(UUID id,String hash,String actor,ExecutionStatus status){} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresResolutionStore.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresResolutionStore.java new file mode 100644 index 0000000..7a09717 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresResolutionStore.java @@ -0,0 +1,101 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.*; +import com.networknt.schema.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.execution.application.port.ResolutionStore; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** Operator attestation is durable evidence, not an automated provider verification. No external IO in this transaction. */ +public final class PostgresResolutionStore implements ResolutionStore { + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String s; + private final FlowJsonCodec codec;private final RequestJson json;private final PublicationCatalog catalog; + private final JsonSchema commandSchema; + public PostgresResolutionStore(DataSource source,String schema,FlowJsonCodec codec,PublicationCatalog catalog){ + if(!schema.matches("orchestrator|t07_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + s="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);this.catalog=catalog; + jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);tx=new TransactionTemplate(new DataSourceTransactionManager(source));tx.setIsolationLevel(2);tx.setTimeout(30); + commandSchema=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(SchemaLocation.of("classpath:schemas/api.schema.json#/$defs/ReconciliationCommand")); + } + public ObjectValue resolve(UUID id,String key,ObjectValue command,Actor actor,String correlation){ + if(!actor.roles().contains("EXECUTION_OPERATOR"))throw new DomainException(ErrorCode.ACCESS_DENIED); + String canonical=json.data(command);JsonNode n=codec.tree(command); + if(!commandSchema.validate(n).isEmpty()||n.path("evidence").path("reference").asText().isBlank()||n.path("evidence").path("reason").asText().isBlank())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + String hash=hash(canonical); + return tx.execute(status->{ + var ids=jdbc.queryForList("SELECT request_id FROM "+s+"execution WHERE id=?",UUID.class,id); + if(ids.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND); + var request=jdbc.queryForMap("SELECT * FROM "+s+"request_instance WHERE id=? FOR UPDATE",ids.getFirst()); + if(!actor.businessScope().equals(request.get("business_scope")))throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND); + var execution=jdbc.queryForMap("SELECT * FROM "+s+"execution WHERE id=? FOR UPDATE",id); + var prior=jdbc.queryForList("SELECT payload_hash,actor_subject,result::text FROM "+s+"reconciliation_resolution WHERE execution_id=? AND idempotency_key=?",id,key); + if(!prior.isEmpty()){ + var previous=prior.getFirst(); + if(!hash.equals(previous.get("payload_hash"))||!actor.subject().equals(previous.get("actor_subject")))throw new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT); + return (ObjectValue)codec.value(json.parse((String)previous.get("result"))); + } + if(!execution.get("status").equals("RECONCILIATION_REQUIRED"))throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED); + boolean resume=n.get("mode").asText().equals("RESUME"); + if(!resume&&n.get("effectStatus").asText().equals("ABSENT_VERIFIED") + &&Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"action_execution WHERE execution_id=? AND effect<>'READ_ONLY' AND status='SUCCEEDED')",Boolean.class,id)))throw invalid(); + if(resume){ + if(!request.get("revision").equals(execution.get("accepted_revision"))||!request.get("current_state").equals(execution.get("from_state")))throw new DomainException(ErrorCode.REVISION_CONFLICT); + var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id"))); + var transition=flow.transitions().stream().filter(t->t.id().equals(execution.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + var definitions=new HashMap();transition.actions().forEach(a->definitions.put(a.id(),a)); + var checkpoints=jdbc.queryForList("SELECT * FROM "+s+"action_execution WHERE execution_id=? ORDER BY action_id FOR UPDATE",id); + var remaining=new HashSet();for(var c:checkpoints)if(!c.get("status").equals("SUCCEEDED"))remaining.add((String)c.get("action_id")); + var seen=new HashSet(); + for(var verified:n.get("verifiedActions")){ + String action=verified.get("actionId").asText(); + if(!seen.add(action))throw invalid(); + var checkpoint=checkpoints.stream().filter(c->action.equals(c.get("action_id"))).findFirst().orElseThrow(PostgresResolutionStore::invalid); + var definition=definitions.get(action);if(definition==null)throw invalid(); + String output=json.data((ObjectValue)codec.value(verified.get("result"))); + if(checkpoint.get("status").equals("SUCCEEDED")){ + String existing=codec.canonical(json.parse(checkpoint.get("result").toString())); + if(!existing.equals(output)||(verified.has("providerReference")&&!verified.get("providerReference").asText().equals(checkpoint.get("provider_reference"))))throw invalid(); + continue; + } + // Document references must originate from metadata verification, never manual output injection. + if(!(definition instanceof ActionDefinition.Integration integration))throw invalid(); + var entry=catalog.integrations().get(integration.integrationKey()); + var contract=entry==null?null:entry.contracts().get(integration.contractVersion().orElse("")); + if(contract==null||!contract.effect().name().equals(checkpoint.get("effect")))throw invalid(); + if(integration.profile().isPresent()&&!contract.profiles().getOrDefault(integration.profile().get(),Set.of()).contains(integration.profileVersion().orElse("")))throw invalid(); + JsonNode outputSchema=codec.tree(contract.output().value());inline(outputSchema); + if(!JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(outputSchema,SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()).validate(verified.get("result")).isEmpty())throw invalid(); + jdbc.update("UPDATE "+s+"action_execution SET status='SUCCEEDED',result=?::jsonb,provider_reference=?,error_code=NULL,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=?",output,verified.has("providerReference")?verified.get("providerReference").asText():null,id,action); + remaining.remove(action); + } + if(!remaining.isEmpty())throw invalid(); + } + String target=resume?"PENDING":"FAILED";UUID resolution=UUID.randomUUID(); + String at=jdbc.queryForObject("SELECT clock_timestamp()",java.sql.Timestamp.class).toInstant().toString(); + var view=JsonNodeFactory.instance.objectNode().put("resolutionId",resolution.toString()).put("executionId",id.toString()).put("status",target).put("resolvedAt",at); + jdbc.update("UPDATE "+s+"execution SET status=?,error_code=?,next_attempt_at=clock_timestamp(),lease_owner=NULL,lease_until=NULL,fencing_token=fencing_token+1,updated_at=clock_timestamp() WHERE id=?",target,resume?null:ErrorCode.ACTION_FAILED.name(),id); + if(!resume)jdbc.update("UPDATE "+s+"action_execution SET status='FAILED',error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND status IN ('RUNNING','UNKNOWN')",ErrorCode.ACTION_FAILED.name(),id); + jdbc.update("INSERT INTO "+s+"reconciliation_resolution(id,execution_id,idempotency_key,payload_hash,mode,actor_subject,evidence,verified_actions,effect_status,result) VALUES (?,?,?,?,?,?,?::jsonb,?::jsonb,?,?::jsonb)", + resolution,id,key,hash,n.get("mode").asText(),actor.subject(),codec.canonical(n.get("evidence")),resume?codec.canonical(n.get("verifiedActions")):"[]",resume?null:n.get("effectStatus").asText(),codec.canonical(view)); + var detail=JsonNodeFactory.instance.objectNode().put("resolutionId",resolution.toString()).put("mode",n.get("mode").asText()).put("correlationId",correlation); + jdbc.update("INSERT INTO "+s+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,detail) VALUES (?,?,?,'EXECUTION_RESOLVED',?,?,?,?::jsonb)", + UUID.randomUUID(),ids.getFirst(),id,actor.subject(),request.get("flow_id"),request.get("current_state"),codec.canonical(detail)); + return (ObjectValue)codec.value(view); + }); + } + private static DomainException invalid(){return new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);} + private static void inline(JsonNode n){if(n.has("$ref")||n.has("$dynamicRef"))throw invalid();if(n.isContainerNode())n.forEach(PostgresResolutionStore::inline);} + private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresWorkerStore.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresWorkerStore.java new file mode 100644 index 0000000..babaf1a --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/PostgresWorkerStore.java @@ -0,0 +1,134 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.application.port.WorkerStore; +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import javax.sql.DataSource; +import java.sql.*; +import java.time.Duration; +import java.util.*; +import java.util.function.Supplier; + +/** Every worker mutation is fenced under the execution row lock, with DB-time lease checks before commit. */ +public final class PostgresWorkerStore implements WorkerStore { + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String s;private final FlowJsonCodec codec;private final RequestJson json; + public PostgresWorkerStore(DataSource source,FlowJsonCodec codec,String schema){ + if(!schema.matches("orchestrator|t(?:07|08|09|10)_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + s="\""+schema+"\".";this.codec=codec;json=new RequestJson(codec);jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15); + tx=new TransactionTemplate(new DataSourceTransactionManager(source));tx.setIsolationLevel(2);tx.setTimeout(30); + } + private String owned(){return "id=? AND request_id=? AND status='RUNNING' AND lease_owner=? AND fencing_token=? AND lease_until>clock_timestamp()";} + private Object[] args(Token t){return new Object[]{t.executionId().value(),t.requestId().value(),t.owner(),t.fence()};} + private void check(Token t,boolean lock){ + if(jdbc.query("SELECT id FROM "+s+"execution WHERE "+owned()+(lock?" FOR UPDATE":""),(r,i)->r.getObject(1),args(t)).isEmpty())throw new DomainException(ErrorCode.LEASE_LOST); + } + private T fenced(Token t,Supplier body){return tx.execute(status->{check(t,true);T result=body.get();check(t,false);return result;});} + private static long seconds(Duration duration){long n=duration.toSeconds();if(n<1||n>3600)throw new IllegalArgumentException("Invalid lease");return n;} + public List claim(String owner,int limit,Duration lease){ + Checks.text(owner,200);if(limit<1||limit>100)throw new IllegalArgumentException("Invalid batch");long seconds=seconds(lease); + return tx.execute(status->jdbc.query("WITH candidates AS (SELECT id FROM "+s+"execution WHERE " + +"(status IN ('PENDING','RETRY_WAIT') AND next_attempt_at<=clock_timestamp()) OR (status='RUNNING' AND lease_until<=clock_timestamp()) " + +"ORDER BY next_attempt_at,created_at,id LIMIT ? FOR UPDATE SKIP LOCKED) UPDATE "+s+"execution e SET status='RUNNING',lease_owner=?," + +"lease_until=clock_timestamp()+(? * interval '1 second'),fencing_token=e.fencing_token+1,attempts=e.attempts+1,updated_at=clock_timestamp() " + +"FROM candidates c WHERE e.id=c.id RETURNING e.id,e.request_id,e.fencing_token", + (r,i)->new Token(new ExecutionId(r.getObject(1,UUID.class)),new RequestId(r.getObject(2,UUID.class)),owner,r.getLong(3)),limit,owner,seconds)); + } + public void renew(Token t,Duration lease){long seconds=seconds(lease); + int count=jdbc.update("UPDATE "+s+"execution SET lease_until=clock_timestamp()+(? * interval '1 second') WHERE "+owned(),seconds,t.executionId().value(),t.requestId().value(),t.owner(),t.fence()); + if(count!=1)throw new DomainException(ErrorCode.LEASE_LOST); + } + public Work load(Token t){return fenced(t,()->{ + var row=jdbc.queryForMap("SELECT e.event_data::text,e.actor_snapshot::text,e.transition_id,e.accepted_revision,e.from_state,r.current_state,r.revision,r.flow_id,r.data::text,f.definition::text FROM "+s+"execution e JOIN "+s+"request_instance r ON r.id=e.request_id JOIN "+s+"flow_definition f ON f.id=r.flow_id WHERE e.id=?",t.executionId().value()); + if(!row.get("accepted_revision").equals(row.get("revision"))||!row.get("from_state").equals(row.get("current_state")))throw new DomainException(ErrorCode.REVISION_CONFLICT); + var flow=codec.decode((String)row.get("definition"));var identity=json.parse((String)row.get("actor_snapshot"));var roles=new HashSet();identity.path("roles").forEach(n->roles.add(n.asText())); + var actor=new Actor(identity.get("subject").asText(),identity.get("businessScope").asText(),roles); + var transition=flow.transitions().stream().filter(v->v.id().equals(row.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + return new Work(t,new FlowId((UUID)row.get("flow_id")),flow,transition,value((String)row.get("data")),value((String)row.get("event_data")),actor,((Number)row.get("accepted_revision")).longValue()); + });} + private ObjectValue value(String text){return (ObjectValue)codec.value(json.parse(text));} + private String encode(ObjectValue data){return codec.canonical(codec.tree(data));} + private Checkpoint checkpoint(ResultSet r,int i)throws SQLException{ + return new Checkpoint(r.getString("action_id"),r.getString("status"),r.getString("input_hash"),r.getString("external_idempotency_key"),Effect.valueOf(r.getString("effect")),r.getInt("attempt_count"),Optional.ofNullable(r.getString("result")).map(this::value)); + } + public List checkpoints(Token t){return fenced(t,()->jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=? ORDER BY action_id",this::checkpoint,t.executionId().value()));} + public Checkpoint start(Token t,String action,String hash,String external,Effect effect){return fenced(t,()->{ + var existing=jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=? AND action_id=?",this::checkpoint,t.executionId().value(),action); + if(!existing.isEmpty()){ + var c=existing.getFirst();if(!c.inputHash().equals(hash)||!c.externalKey().equals(external)||c.effect()!=effect)throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + if(c.status().equals("SUCCEEDED"))return c; + if(c.status().equals("UNKNOWN")||(c.status().equals("RUNNING")&&effect==Effect.NON_IDEMPOTENT_WRITE))throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED); + jdbc.update("UPDATE "+s+"action_execution SET status='RUNNING',attempt_count=attempt_count+1,started_at=clock_timestamp(),finished_at=NULL,error_code=NULL WHERE execution_id=? AND action_id=?",t.executionId().value(),action); + }else jdbc.update("INSERT INTO "+s+"action_execution(id,execution_id,action_id,status,attempt_count,external_idempotency_key,input_hash,effect,started_at) VALUES (?,?,?,'RUNNING',1,?,?,?,clock_timestamp())",UUID.randomUUID(),t.executionId().value(),action,external,hash,effect.name()); + return jdbc.queryForObject("SELECT * FROM "+s+"action_execution WHERE execution_id=? AND action_id=?",this::checkpoint,t.executionId().value(),action); + });} + public void save(Token t,String action,String hash,ObjectValue output,Optional provider){tx.executeWithoutResult(status->{var request=lockRequest(t);fenced(t,()->{ + int count=jdbc.update("UPDATE "+s+"action_execution SET status='SUCCEEDED',result=?::jsonb,provider_reference=?,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=? AND input_hash=? AND status='RUNNING'",encode(output),provider.orElse(null),t.executionId().value(),action,hash); + if(count!=1)throw new DomainException(ErrorCode.LEASE_LOST); + registerDocument(t,action,output,request);return null; + });});} + private void registerDocument(Token token,String actionId,ObjectValue output,Map request){ + var execution=jdbc.queryForMap("SELECT transition_id,actor_subject FROM "+s+"execution WHERE id=?",token.executionId().value()); + var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id"))); + var action=flow.transitions().stream().filter(t->t.id().equals(execution.get("transition_id"))).flatMap(t->t.actions().stream()).filter(a->a.id().equals(actionId)).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + if(!(action instanceof com.organizacion.orchestrator.action.domain.ActionDefinition.RegisterDocument document))return; + var n=codec.tree(output); + if(!token.requestId().value().toString().equals(n.path("requestId").asText())||!request.get("business_scope").equals(n.path("businessScope").asText())||!document.documentType().equals(n.path("documentType").asText()))throw new DomainException(ErrorCode.ACCESS_DENIED); + UUID id=UUID.fromString(n.path("documentId").asText());String storage=Checks.text(n.path("storageDocumentId").asText(),200); + String mime=Checks.text(n.path("mimeType").asText(),100),checksum=Checks.text(n.path("checksum").asText(),128); + long size=n.path("sizeBytes").decimalValue().longValueExact(); + jdbc.update("INSERT INTO "+s+"document_reference(id,request_id,document_type,storage_document_id,mime_type,size_bytes,checksum,created_by) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING",id,token.requestId().value(),document.documentType(),storage,mime,size,checksum,execution.get("actor_subject")); + if(!Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"document_reference WHERE id=? AND request_id=? AND document_type=? AND storage_document_id=? AND mime_type=? AND size_bytes=? AND checksum=?)",Boolean.class,id,token.requestId().value(),document.documentType(),storage,mime,size,checksum)))throw new DomainException(ErrorCode.ACCESS_DENIED); + } + private Map lockRequest(Token t){ + var rows=jdbc.queryForList("SELECT * FROM "+s+"request_instance WHERE id=? FOR UPDATE",t.requestId().value());if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst(); + } + public void complete(Token t,Completion completion){tx.executeWithoutResult(status->{ + var request=lockRequest(t);check(t,true);var execution=jdbc.queryForMap("SELECT * FROM "+s+"execution WHERE id=?",t.executionId().value()); + if(!request.get("flow_id").equals(completion.flowId().value())||!request.get("current_state").equals(execution.get("from_state")) + ||!request.get("revision").equals(execution.get("accepted_revision")))throw new DomainException(ErrorCode.REVISION_CONFLICT); + // Every action in the pinned transition must have its durable successful checkpoint. + var flow=codec.decode(jdbc.queryForObject("SELECT definition::text FROM "+s+"flow_definition WHERE id=?",String.class,request.get("flow_id"))); + var transition=flow.transitions().stream().filter(v->v.id().equals(execution.get("transition_id"))).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + var expected=new HashSet();transition.actions().forEach(a->expected.add(a.id())); + var checkpoints=jdbc.query("SELECT * FROM "+s+"action_execution WHERE execution_id=?",this::checkpoint,t.executionId().value()); + var actual=new HashSet();checkpoints.forEach(c->actual.add(c.actionId())); + if(!actual.equals(expected)||checkpoints.stream().anyMatch(c->!c.status().equals("SUCCEEDED")))throw new DomainException(ErrorCode.ACTION_FAILED); + jdbc.update("UPDATE "+s+"request_instance SET current_state=?,data=?::jsonb,revision=revision+1,updated_at=clock_timestamp() WHERE id=?",completion.toState(),json.data(completion.data()),t.requestId().value()); + audit(t,"TRANSITION_COMPLETED",(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),completion.toState(),completion.audit()); + int changed=jdbc.update("UPDATE "+s+"execution SET status='SUCCEEDED',result=?::jsonb,error_code=NULL,lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),encode(completion.result()),t.executionId().value(),t.requestId().value(),t.owner(),t.fence()); + if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST); + });} + private void audit(Token t,String kind,String actor,UUID flow,String from,String to,ObjectValue detail){ + jdbc.update("INSERT INTO "+s+"audit_entry(id,request_id,execution_id,kind,actor_subject,flow_id,from_state,to_state,detail) VALUES (?,?,?,?,?,?,?,?,?::jsonb)",UUID.randomUUID(),t.requestId().value(),t.executionId().value(),kind,actor,flow,from,to,encode(detail)); + } + public void fail(Token t,ErrorCode error,boolean uncertain){tx.executeWithoutResult(status->{ + var request=lockRequest(t);check(t,true);var execution=jdbc.queryForMap("SELECT actor_subject,from_state FROM "+s+"execution WHERE id=?",t.executionId().value()); + // Completed writes and in-flight writes are never silently released as FAILED. + boolean writes=Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM "+s+"action_execution WHERE execution_id=? AND effect<>'READ_ONLY' AND status IN ('RUNNING','SUCCEEDED','UNKNOWN','FAILED'))",Boolean.class,t.executionId().value())); + boolean reconcile=uncertain||writes;String target=reconcile?"RECONCILIATION_REQUIRED":"FAILED"; + jdbc.update("UPDATE "+s+"action_execution SET status=?,error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND status='RUNNING'",reconcile?"UNKNOWN":"FAILED",error.name(),t.executionId().value()); + audit(t,target,(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),null,new ObjectValue(Map.of("code",new JsonValue.StringValue(error.name())))); + int changed=jdbc.update("UPDATE "+s+"execution SET status=?,error_code=?,lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),target,error.name(),t.executionId().value(),t.requestId().value(),t.owner(),t.fence()); + if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST); + });} + public void retry(Token t,String action,ErrorCode error,Duration delay){ + long millis=delay.toMillis();if(millis<100||millis>120000)throw new IllegalArgumentException("Invalid retry delay"); + tx.executeWithoutResult(status->{var request=lockRequest(t);check(t,true); + var execution=jdbc.queryForMap("SELECT actor_subject,from_state FROM "+s+"execution WHERE id=?",t.executionId().value()); + int count=jdbc.update("UPDATE "+s+"action_execution SET status='FAILED',error_code=?,finished_at=clock_timestamp() WHERE execution_id=? AND action_id=? AND status='RUNNING' AND effect IN ('READ_ONLY','IDEMPOTENT_WRITE')",error.name(),t.executionId().value(),action); + if(count!=1)throw new DomainException(ErrorCode.RECONCILIATION_REQUIRED); + audit(t,"RETRY_SCHEDULED",(String)execution.get("actor_subject"),(UUID)request.get("flow_id"),(String)execution.get("from_state"),null, + new ObjectValue(Map.of("actionId",new JsonValue.StringValue(action),"code",new JsonValue.StringValue(error.name()),"delayMs",new JsonValue.NumberValue(java.math.BigDecimal.valueOf(millis))))); + int changed=jdbc.update("UPDATE "+s+"execution SET status='RETRY_WAIT',error_code=?,next_attempt_at=clock_timestamp()+(? * interval '1 millisecond'),lease_owner=NULL,lease_until=NULL,updated_at=clock_timestamp() WHERE "+owned(),error.name(),millis,t.executionId().value(),t.requestId().value(),t.owner(),t.fence()); + if(changed!=1)throw new DomainException(ErrorCode.LEASE_LOST); + }); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/ResolutionConfiguration.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/ResolutionConfiguration.java new file mode 100644 index 0000000..83e6b68 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/ResolutionConfiguration.java @@ -0,0 +1,18 @@ +package com.organizacion.orchestrator.execution.infrastructure; +import com.organizacion.orchestrator.execution.application.Resolutions; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true") +public class ResolutionConfiguration { + @Bean Resolutions resolutions(DataSource source,FlowJsonCodec codec,PublicationCatalog catalog,ResourceAccess access,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){ + return new Resolutions(new PostgresResolutionStore(source,schema,codec,catalog),access); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/TransitionEvaluator.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/TransitionEvaluator.java new file mode 100644 index 0000000..cb03560 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/TransitionEvaluator.java @@ -0,0 +1,69 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.decision.application.PureDecisionEvaluator; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import com.networknt.schema.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import java.util.*; + +/** Deterministic projection shared by durable execution and administrative simulation. No I/O. */ +public final class TransitionEvaluator { + private final FlowJsonCodec codec;private final RequestJson json; + public TransitionEvaluator(FlowJsonCodec codec){this.codec=codec;json=new RequestJson(codec);} + private EvaluationContext context(Work w,Map results){return new EvaluationContext(w.data(),w.event(),new ObjectValue(results),w.actor());} + private JsonValue value(ValueExpression expression,EvaluationContext context){ + if(expression instanceof ValueExpression.Literal l)return l.value(); + var found=new JsonPointerResolver().resolve((ValueExpression.Path)expression,context); + if(found instanceof Lookup.Present present)return present.value();throw new DomainException(ErrorCode.EVALUATION_ERROR); + } + private void validate(InlineSchema schema,ObjectValue data){ + var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(schema.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + } + public Completion complete(Work work,Map results){ + var context=context(work,results);String outcome,to;List rules=List.of();String policy="FIXED"; + if(work.transition().routing() instanceof Transition.Routing.Fixed fixed){outcome=fixed.outcome();to=fixed.to();} + else{ + var route=(Transition.Routing.Decision)work.transition().routing(); + var definition=work.flow().decisions().stream().filter(d->d.id().equals(route.decision())).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + var decision=new PureDecisionEvaluator().evaluate(definition,context); + if(decision instanceof DecisionResult.Failure failed)throw new DomainException(failed.code()); + var selected=(DecisionResult.Selected)decision;outcome=selected.output();rules=selected.matchedRuleIds();policy=definition.hitPolicy().name();to=route.destinations().get(outcome); + if(to==null)throw new DomainException(ErrorCode.INVALID_FLOW); + } + JsonNode data=codec.tree(work.data()).deepCopy(); + for(var update:work.transition().updates())put(data,update.target(),codec.tree(value(update.value(),context))); + ObjectValue candidate=(ObjectValue)codec.value(data);json.data(candidate); + try{validate(work.flow().dataSchema().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)),candidate);} + catch(DomainException e){throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR);} + var business=new ObjectValue(Map.of("outcome",new StringValue(outcome),"fromState",new StringValue(work.transition().from()),"toState",new StringValue(to))); + var auditFields=new HashMap(Map.of("transitionId",new StringValue(work.transition().id()),"hitPolicy",new StringValue(policy), + "ruleIds",new ArrayValue(rules.stream().map(v->(JsonValue)new StringValue(v)).toList()), + "actionIds",new ArrayValue(work.transition().actions().stream().map(a->(JsonValue)new StringValue(a.id())).toList()))); + var reasons=results.values().stream().filter(v->v instanceof ObjectValue).map(v->((ObjectValue)v).fields().get("reasonCodes")) + .filter(v->v instanceof ArrayValue).flatMap(v->((ArrayValue)v).values().stream()).filter(v->v instanceof StringValue).distinct().toList(); + if(!reasons.isEmpty())auditFields.put("reasonCodes",new ArrayValue(reasons)); + return new Completion(work.flowId(),to,candidate,business,new ObjectValue(auditFields)); + } + private static void put(JsonNode root,String pointer,JsonNode value){ + String[] parts=pointer.substring(1).split("/",-1);JsonNode current=root; + for(int i=0;i=array.size())throw new DomainException(ErrorCode.EVALUATION_ERROR);if(last){array.set(index,value);return;}current=array.get(index); + }else throw new DomainException(ErrorCode.EVALUATION_ERROR); + } + } +} + diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerConfiguration.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerConfiguration.java new file mode 100644 index 0000000..544ee81 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerConfiguration.java @@ -0,0 +1,22 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.application.port.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.*; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; +import java.util.Optional; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.worker.enabled",havingValue="true") +public class WorkerConfiguration { + @Bean WorkerStore workerStore(DataSource source,FlowJsonCodec codec,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresWorkerStore(source,codec,schema);} + @Bean WorkerEngine workerEngine(WorkerStore store,WorkerActions actions,FlowJsonCodec codec){return new WorkerEngine(store,actions,codec);} + @Bean WorkerLoop workerLoop(WorkerStore store,WorkerEngine engine,@Value("${orchestrator.worker.concurrency:10}")int concurrency, + @Value("${orchestrator.worker.batch:10}")int batch,@Value("${orchestrator.worker.poll-ms:500}")long poll, + @Value("${orchestrator.worker.lease-seconds:60}")long lease,@Value("${orchestrator.worker.renew-seconds:15}")long renew, + @Value("${orchestrator.worker.shutdown-seconds:20}")int shutdown){return new WorkerLoop(store,engine,concurrency,batch,poll,lease,renew,shutdown);} +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerEngine.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerEngine.java new file mode 100644 index 0000000..5955dbc --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerEngine.java @@ -0,0 +1,98 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.application.port.*; +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.decision.application.PureDecisionEvaluator; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import com.networknt.schema.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; + +/** Executes outside database transactions. All effects pass through an explicit trusted adapter registry. */ +public final class WorkerEngine { + private static final org.slf4j.Logger log=org.slf4j.LoggerFactory.getLogger(WorkerEngine.class); + private final WorkerStore store;private final WorkerActions actions;private final FlowJsonCodec codec;private final RequestJson json; + public WorkerEngine(WorkerStore store,WorkerActions actions,FlowJsonCodec codec){this.store=store;this.actions=actions;this.codec=codec;json=new RequestJson(codec);} + public void run(Token token){ + try{ + var work=store.load(token);var results=new LinkedHashMap(); + log.info("worker requestId={} executionId={} flowId={} version={} phase=loaded",token.requestId().value(),token.executionId().value(),work.flowId().value(),work.flow().version()); + for(var action:work.transition().actions()){ + if(action instanceof ActionDefinition.NoOp)throw new DomainException(ErrorCode.ACTION_FAILED); + var capability=actions.capability(action).orElseThrow(()->new DomainException(ErrorCode.INTEGRATION_UNAVAILABLE)); + if(!capability.type().equals(action.type()))throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + ObjectValue inputs=inputs(action,context(work,results));validate(capability.inputSchema(),inputs); + String hash=hash(inputs),external=token.executionId().value()+":"+action.id(); + var previous=store.checkpoints(token).stream().filter(c->c.actionId().equals(action.id())).findFirst(); + var retry=action instanceof ActionDefinition.Integration i?i.retry():((ActionDefinition.RegisterDocument)action).retry(); + int max=retry.maxAttempts(); + if(previous.isPresent()&&(previous.get().status().equals("UNKNOWN")||previous.get().status().equals("RUNNING")&&previous.get().effect()==Effect.NON_IDEMPOTENT_WRITE)){store.fail(token,ErrorCode.RECONCILIATION_REQUIRED,true);return;} + if(previous.isPresent()&&!previous.get().status().equals("SUCCEEDED")&&previous.get().attempts()>=max){store.fail(token,ErrorCode.ACTION_FAILED,false);return;} + var checkpoint=store.start(token,action.id(),hash,external,capability.effect()); + ObjectValue output; + if(checkpoint.status().equals("SUCCEEDED"))output=checkpoint.output().orElseThrow(()->new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR)); + else{ + ActionResult response; + long started=System.nanoTime(); + try{response=actions.execute(action,new ActionContext(token.executionId(),token.requestId(),work.actor(),inputs,external,checkpoint.attempts()));} + catch(RuntimeException e){response=new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,capability.effect()==Effect.READ_ONLY?ActionResult.FailureKind.TRANSIENT:ActionResult.FailureKind.UNKNOWN_EFFECT);} + log.info("action requestId={} executionId={} flowId={} version={} actionId={} attempt={} result={} durationMs={}",token.requestId().value(),token.executionId().value(),work.flowId().value(),work.flow().version(),action.id(),checkpoint.attempts(),response instanceof ActionResult.Completed?"completed":"technical_failure",(System.nanoTime()-started)/1_000_000); + if(response instanceof ActionResult.TechnicalFailure failure){ + if(failure.kind()==ActionResult.FailureKind.TRANSIENT&&capability.effect()!=Effect.NON_IDEMPOTENT_WRITE&&checkpoint.attempts() results){return new EvaluationContext(w.data(),w.event(),new ObjectValue(results),w.actor());} + private JsonValue value(ValueExpression expression,EvaluationContext context){ + if(expression instanceof ValueExpression.Literal l)return l.value(); + var found=new JsonPointerResolver().resolve((ValueExpression.Path)expression,context); + if(found instanceof Lookup.Present present)return present.value();throw new DomainException(ErrorCode.EVALUATION_ERROR); + } + private ObjectValue inputs(ActionDefinition action,EvaluationContext context){ + var values=new HashMap(); + if(action instanceof ActionDefinition.Integration i)i.inputs().forEach((k,v)->values.put(k,value(v,context))); + else if(action instanceof ActionDefinition.RegisterDocument d)values.put("documentId",value(d.documentId(),context)); + else throw new DomainException(ErrorCode.ACTION_FAILED);return new ObjectValue(values); + } + private String hash(ObjectValue input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.data(input).getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private void validate(InlineSchema schema,ObjectValue data){ + var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(schema.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + if(!validator.validate(codec.tree(data)).isEmpty())throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + } + private void validateOutput(Work work,ActionDefinition action,ActionCapability capability,ObjectValue output){ + validate(capability.outputSchema(),output); + if(action instanceof ActionDefinition.RegisterDocument document) + work.flow().tasks().stream().filter(t->t.state().equals(work.transition().from())&&t.event().equals(work.transition().event())) + .map(TaskDefinition::ui).filter(ui->ui instanceof TaskDefinition.Ui.DocumentUpload) + .map(ui->(TaskDefinition.Ui.DocumentUpload)ui).filter(ui->ui.documentType().equals(document.documentType())) + .forEach(ui->com.organizacion.orchestrator.document.domain.DocumentMetadata.requireLimits(ui.documentType(),ui.acceptedMimeTypes(),ui.maxBytes(),output)); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerLoop.java b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerLoop.java new file mode 100644 index 0000000..ddcb9d2 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/execution/infrastructure/WorkerLoop.java @@ -0,0 +1,58 @@ +package com.organizacion.orchestrator.execution.infrastructure; + +import com.organizacion.orchestrator.execution.application.port.WorkerStore; +import com.organizacion.orchestrator.execution.domain.WorkerState.Token; +import org.springframework.context.SmartLifecycle; +import org.slf4j.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Bounded local executors only dispatch jobs already durably claimed in PostgreSQL. */ +public final class WorkerLoop implements SmartLifecycle { + private static final Logger log=LoggerFactory.getLogger(WorkerLoop.class); + private final WorkerStore store;private final WorkerEngine engine;private final int concurrency,batch;private final long pollMs,renewMs;private final Duration lease; + private final String owner="worker-"+UUID.randomUUID();private final AtomicBoolean running=new AtomicBoolean(); + private ScheduledExecutorService poller,renewals;private ExecutorService jobs;private Semaphore slots; + private final int shutdownSeconds; + public WorkerLoop(WorkerStore store,WorkerEngine engine,int concurrency,int batch,long pollMs,long leaseSeconds,long renewSeconds){ + this(store,engine,concurrency,batch,pollMs,leaseSeconds,renewSeconds,20); + } + public WorkerLoop(WorkerStore store,WorkerEngine engine,int concurrency,int batch,long pollMs,long leaseSeconds,long renewSeconds,int shutdownSeconds){ + if(concurrency<1||concurrency>100||batch<1||batch>100||pollMs<100||leaseSeconds<3||leaseSeconds>3600||renewSeconds<1||renewSeconds*2>=leaseSeconds)throw new IllegalArgumentException("Invalid worker settings"); + if(shutdownSeconds<1||shutdownSeconds>20)throw new IllegalArgumentException("Invalid shutdown budget");this.shutdownSeconds=shutdownSeconds; + this.store=store;this.engine=engine;this.concurrency=concurrency;this.batch=batch;this.pollMs=pollMs;lease=Duration.ofSeconds(leaseSeconds);renewMs=renewSeconds*1000; + } + public synchronized void start(){ + if(!running.compareAndSet(false,true))return;slots=new Semaphore(concurrency);jobs=Executors.newFixedThreadPool(concurrency); + renewals=Executors.newScheduledThreadPool(Math.min(concurrency,4));poller=Executors.newSingleThreadScheduledExecutor(); + poller.scheduleWithFixedDelay(this::tick,0,pollMs,TimeUnit.MILLISECONDS); + } + private void tick(){ + if(!running.get())return;int free=Math.min(batch,slots.availablePermits());if(free==0)return; + try{for(Token token:store.claim(owner,free,lease)){ + slots.acquireUninterruptibly();try{jobs.execute(()->run(token));}catch(RejectedExecutionException e){slots.release();} + }}catch(RuntimeException e){log.warn("Worker claim failed: {}",e.getClass().getSimpleName());} + } + private void run(Token token){ + Thread thread=Thread.currentThread();Object gate=new Object();AtomicBoolean active=new AtomicBoolean(true); + var heartbeat=renewals.scheduleAtFixedRate(()->{ + try{store.renew(token,lease);}catch(RuntimeException e){ + synchronized(gate){if(active.get())thread.interrupt();} + log.warn("Worker renewal failed: {}",e.getClass().getSimpleName()); + } + },renewMs,renewMs,TimeUnit.MILLISECONDS); + try{engine.run(token);}catch(RuntimeException e){log.warn("Worker processing stopped; lease recovery pending: {}",e.getClass().getSimpleName());} + finally{synchronized(gate){active.set(false);}heartbeat.cancel(false);slots.release();Thread.interrupted();} + } + public synchronized void stop(){ + if(!running.compareAndSet(true,false))return;poller.shutdownNow(); + try{poller.awaitTermination(2,TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();} + jobs.shutdown();try{if(!jobs.awaitTermination(shutdownSeconds,TimeUnit.SECONDS)){jobs.shutdownNow();jobs.awaitTermination(1,TimeUnit.SECONDS);}}catch(InterruptedException e){jobs.shutdownNow();Thread.currentThread().interrupt();} + renewals.shutdownNow(); // Unfinished work keeps its durable lease/checkpoints and is recovered after expiry. + } + public void stop(Runnable callback){try{stop();}finally{callback.run();}} + public boolean isRunning(){return running.get();} + public int getPhase(){return Integer.MAX_VALUE;} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/api/FlowAdministrationController.java b/src/main/java/com/organizacion/orchestrator/flow/api/FlowAdministrationController.java new file mode 100644 index 0000000..37285b8 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/api/FlowAdministrationController.java @@ -0,0 +1,90 @@ +package com.organizacion.orchestrator.flow.api; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import com.organizacion.orchestrator.flow.application.FlowAdministration; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.api.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.io.IOException; +import java.net.URI; +import java.nio.*; +import java.nio.charset.*; +import java.util.*; + +@RestController +@RequestMapping("/api/v1/admin") +@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true") +public class FlowAdministrationController { + private final FlowAdministration service;private final JwtActorMapper actors;private final FlowJsonCodec codec; + private final ObjectMapper json=new ObjectMapper(JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + public FlowAdministrationController(FlowAdministration service,JwtActorMapper actors,FlowJsonCodec codec){this.service=service;this.actors=actors;this.codec=codec;} + @PostMapping(value="/flows",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{ + String document=body(request);syntax(document); + com.organizacion.orchestrator.flow.domain.FlowVersion flow; + try{flow=service.create(document,actors.actor(jwt),CorrelationFilter.id(request));} + catch(IllegalArgumentException e){throw new ApiException(422,"SCHEMA_VALIDATION_ERROR");} + return ResponseEntity.created(URI.create("/api/v1/admin/flows/"+flow.id().value())).eTag(Long.toString(flow.revision())).body(FlowView.from(flow,codec)); + } + @PutMapping(value="/flows/{id}",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity edit(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match, + @AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{ + long revision=revision(match); + String document=body(request);syntax(document); + try{return view(service.edit(new FlowId(id),revision,document,actors.actor(jwt),CorrelationFilter.id(request)));} + catch(IllegalArgumentException e){throw new ApiException(422,"SCHEMA_VALIDATION_ERROR");} + } + @PostMapping(value="/flows/{id}/validate",produces=MediaType.APPLICATION_JSON_VALUE) + public ValidationReportResponse validate(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + return ValidationReportResponse.from(service.validate(new FlowId(id),actors.actor(jwt))); + } + @PostMapping(value="/flows/{id}/publish",produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity publish(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match, + @AuthenticationPrincipal Jwt jwt,HttpServletRequest request){ + return view(service.publish(new FlowId(id),revision(match),actors.actor(jwt),CorrelationFilter.id(request))); + } + @PostMapping(value="/flows/{id}/retire",produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity retire(@PathVariable UUID id,@RequestHeader(value="If-Match",required=false)String match, + @AuthenticationPrincipal Jwt jwt,HttpServletRequest request){ + return view(service.retire(new FlowId(id),revision(match),actors.actor(jwt),CorrelationFilter.id(request))); + } + @PutMapping(value="/request-types/{key}/active-flow",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> activate(@PathVariable String key,@RequestHeader(value="If-Match",required=false)String match, + @AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{ + long revision=revision(match); + JsonNode input; + try{input=json.readTree(body(request));}catch(JsonProcessingException e){throw new ApiException(400,"INVALID_REQUEST");} + if(input==null||!input.isObject()||input.size()!=1||!input.path("flowId").isTextual())throw new ApiException(400,"INVALID_REQUEST"); + var type=service.activate(key,new FlowId(UUID.fromString(input.get("flowId").textValue())),revision,actors.actor(jwt),CorrelationFilter.id(request)); + return ResponseEntity.ok().eTag(Long.toString(type.revision())).body(Map.of("typeKey",type.typeKey(),"activeFlowId",type.activeFlowId().orElseThrow().value(), + "enabled",type.enabled(),"revision",type.revision())); + } + private ResponseEntity view(com.organizacion.orchestrator.flow.domain.FlowVersion flow){ + return ResponseEntity.ok().eTag(Long.toString(flow.revision())).body(FlowView.from(flow,codec)); + } + private void syntax(String document){ + try{if(json.readTree(document)==null)throw new ApiException(400,"INVALID_REQUEST");} + catch(JsonProcessingException e){throw new ApiException(400,"INVALID_REQUEST");} + } + static long revision(String match){ + if(match==null)throw new ApiException(428,"PRECONDITION_REQUIRED"); + if(!match.matches("\"(0|[1-9][0-9]*)\""))throw new ApiException(400,"INVALID_IF_MATCH"); + try{return Long.parseLong(match.substring(1,match.length()-1));}catch(NumberFormatException e){throw new ApiException(400,"INVALID_IF_MATCH");} + } + private static String body(HttpServletRequest request)throws IOException{ + if(request.getContentLengthLong()>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + byte[] bytes=request.getInputStream().readNBytes(FlowJsonCodec.MAX_BYTES+1); + if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + try{return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();} + catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");} + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/api/FlowView.java b/src/main/java/com/organizacion/orchestrator/flow/api/FlowView.java new file mode 100644 index 0000000..10d0927 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/api/FlowView.java @@ -0,0 +1,22 @@ +package com.organizacion.orchestrator.flow.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.organizacion.orchestrator.flow.domain.FlowVersion; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import java.time.Instant; +import java.util.*; + +/** Explicit DTO; nested definition is plain JSON-compatible data, not Jackson 2 nodes or domain entities. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FlowView(UUID id,String status,long revision,Map definition,String checksum, + Instant createdAt,String createdBy,Instant publishedAt){ + private static final ObjectMapper JSON=new ObjectMapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + @SuppressWarnings("unchecked") + public static FlowView from(FlowVersion version,FlowJsonCodec codec){ + Map definition=JSON.convertValue(codec.tree(version.definition()),Map.class); + return new FlowView(version.id().value(),version.status().name(),version.revision(),definition, + version.checksum().orElse(null),version.createdAt(),version.createdBy(),version.publishedAt().orElse(null)); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/api/SimulationController.java b/src/main/java/com/organizacion/orchestrator/flow/api/SimulationController.java new file mode 100644 index 0000000..7fc70c4 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/api/SimulationController.java @@ -0,0 +1,33 @@ +package com.organizacion.orchestrator.flow.api; + +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.flow.infrastructure.*; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.api.ApiException; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import java.nio.*; +import java.nio.charset.*; +import java.util.UUID; + +@RestController +@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true") +public class SimulationController { + private final FlowStore store;private final FlowSimulation simulation;private final RequestJson json; + public SimulationController(FlowStore store,PublicationCatalog catalog,FlowJsonCodec codec){this.store=store;simulation=new FlowSimulation(catalog,codec);json=new RequestJson(codec);} + @PostMapping(value="/api/v1/admin/flows/{id}/simulate",consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity simulate(@PathVariable UUID id,HttpServletRequest request)throws java.io.IOException{ + // FLOW_EDITOR and configured admin scope are enforced by the SecurityFilterChain, before parsing fixtures. + if(request.getContentLengthLong()>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + byte[] bytes=request.getInputStream().readNBytes(FlowJsonCodec.MAX_BYTES+1);if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + String body;try{body=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();} + catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");} + var fixture=json.parse(body);if(fixture==null)throw new ApiException(400,"INVALID_REQUEST"); + var version=store.transaction(tx->tx.lock(new FlowId(id))); // Release the read lock before pure evaluation. + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(simulation.simulate(version,fixture)); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/api/ValidationReportResponse.java b/src/main/java/com/organizacion/orchestrator/flow/api/ValidationReportResponse.java new file mode 100644 index 0000000..38f90d1 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/api/ValidationReportResponse.java @@ -0,0 +1,23 @@ +package com.organizacion.orchestrator.flow.api; + +import com.organizacion.orchestrator.flow.domain.ValidationReport; +import java.util.List; +import java.util.Objects; + +/** Transport DTO matching api.schema.json; never return domain records directly from controllers. */ +public record ValidationReportResponse(boolean valid, List errors) { + public ValidationReportResponse { + errors = List.copyOf(errors); + if (valid != errors.isEmpty()) throw new IllegalArgumentException("Inconsistent validation response"); + } + public record Violation(String pointer, String code, String message) { + public Violation { + Objects.requireNonNull(pointer); Objects.requireNonNull(code); Objects.requireNonNull(message); + } + } + public static ValidationReportResponse from(ValidationReport report) { + var errors = report.issues().stream().filter(i -> i.severity() == ValidationReport.Severity.ERROR) + .map(i -> new Violation(i.pointer(), i.code(), i.message())).toList(); + return new ValidationReportResponse(report.valid(), errors); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/application/FlowAdministration.java b/src/main/java/com/organizacion/orchestrator/flow/application/FlowAdministration.java new file mode 100644 index 0000000..7081282 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/application/FlowAdministration.java @@ -0,0 +1,73 @@ +package com.organizacion.orchestrator.flow.application; + +import com.organizacion.orchestrator.flow.application.port.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.request.domain.RequestType; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.*; + +/** Internal administrative API. Actor must come from trusted authentication, never request JSON. */ +public final class FlowAdministration { + private final FlowStore store;private final FlowPreparation preparation; + public FlowAdministration(FlowStore store,FlowPreparation preparation){this.store=Objects.requireNonNull(store);this.preparation=Objects.requireNonNull(preparation);} + public FlowVersion create(String json,Actor actor,String correlationId){ + authorize(actor,"FLOW_EDITOR",correlationId);var definition=preparation.decode(json); + return store.transaction(tx->{ + var saved=tx.insert(definition,actor.subject()); + tx.audit(saved.id(),"CREATE",actor.subject(),correlationId,Optional.empty(),Optional.empty(),ObjectValue.empty());return saved; + }); + } + public FlowVersion edit(FlowId id,long expectedRevision,String json,Actor actor,String correlationId){ + authorize(actor,"FLOW_EDITOR",correlationId);var definition=preparation.decode(json); + return store.transaction(tx->{ + var current=tx.lock(id);revision(current.revision(),expectedRevision);draft(current); + if(!definition.flowKey().equals(current.definition().flowKey())||definition.version()!=current.definition().version()) + throw new DomainException(ErrorCode.INVALID_FLOW); + var saved=tx.update(current,definition,FlowVersion.Status.DRAFT,Optional.empty()); + tx.audit(id,"EDIT",actor.subject(),correlationId,Optional.of(preparation.checksum(current.definition())), + Optional.of(preparation.checksum(definition)),ObjectValue.empty());return saved; + }); + } + public ValidationReport validate(FlowId id,Actor actor){ + if(!actor.roles().contains("FLOW_EDITOR")&&!actor.roles().contains("FLOW_PUBLISHER"))throw new DomainException(ErrorCode.ACCESS_DENIED); + return store.transaction(tx->preparation.validate(tx.lock(id).definition())); + } + public FlowVersion publish(FlowId id,long expectedRevision,Actor actor,String correlationId){ + authorize(actor,"FLOW_PUBLISHER",correlationId); + return store.transaction(tx->{ + var current=tx.lock(id);revision(current.revision(),expectedRevision);draft(current); + var report=preparation.validate(current.definition());if(!report.valid())throw new FlowValidationException(report); + var definition=preparation.resolve(current.definition());var checksum=Optional.of(preparation.checksum(definition)); + var saved=tx.update(current,definition,FlowVersion.Status.PUBLISHED,checksum); + tx.audit(id,"PUBLISH",actor.subject(),correlationId,Optional.of(preparation.checksum(current.definition())),checksum,ObjectValue.empty());return saved; + }); + } + public RequestType activate(String typeKey,FlowId id,long expectedRevision,Actor actor,String correlationId){ + authorize(actor,"FLOW_ADMIN",correlationId);Checks.key(typeKey); + return store.transaction(tx->{ + var flow=tx.lock(id);if(!flow.assignable())throw new DomainException(ErrorCode.INVALID_FLOW); + var type=tx.lockType(typeKey);revision(type.revision(),expectedRevision); + if(!type.enabled())throw new DomainException(ErrorCode.INVALID_FLOW); + var detail=new HashMap();detail.put("typeKey",new StringValue(typeKey)); + type.activeFlowId().ifPresent(previous->detail.put("previousFlowId",new StringValue(previous.value().toString()))); + var saved=tx.activate(type,id); + tx.audit(id,"ACTIVATE",actor.subject(),correlationId,Optional.empty(),flow.checksum(),new ObjectValue(detail));return saved; + }); + } + public FlowVersion retire(FlowId id,long expectedRevision,Actor actor,String correlationId){ + authorize(actor,"FLOW_ADMIN",correlationId); + return store.transaction(tx->{ + var flow=tx.lock(id);revision(flow.revision(),expectedRevision); + if(flow.status()!=FlowVersion.Status.PUBLISHED)throw new DomainException(ErrorCode.INVALID_FLOW); + var deactivated=tx.clearActive(id); + var saved=tx.update(flow,flow.definition(),FlowVersion.Status.RETIRED,flow.checksum()); + tx.audit(id,"RETIRE",actor.subject(),correlationId,flow.checksum(),flow.checksum(), + new ObjectValue(Map.of("deactivatedTypeKeys",new ArrayValue(deactivated.stream().map(k->(JsonValue)new StringValue(k)).toList()))));return saved; + }); + } + private static void draft(FlowVersion flow){if(flow.status()!=FlowVersion.Status.DRAFT)throw new DomainException(ErrorCode.INVALID_FLOW);} + private static void revision(long actual,long expected){Checks.nonNegative(expected);if(actual!=expected)throw new DomainException(ErrorCode.REVISION_CONFLICT);} + private static void authorize(Actor actor,String role,String correlation){Checks.text(correlation,200);if(!actor.roles().contains(role))throw new DomainException(ErrorCode.ACCESS_DENIED);} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/application/FlowValidationException.java b/src/main/java/com/organizacion/orchestrator/flow/application/FlowValidationException.java new file mode 100644 index 0000000..0891d71 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/application/FlowValidationException.java @@ -0,0 +1,9 @@ +package com.organizacion.orchestrator.flow.application; + +import com.organizacion.orchestrator.flow.domain.ValidationReport; + +public final class FlowValidationException extends RuntimeException { + private final ValidationReport report; + public FlowValidationException(ValidationReport report){super("INVALID_FLOW");this.report=report;} + public ValidationReport report(){return report;} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowDefinitionValidator.java b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowDefinitionValidator.java new file mode 100644 index 0000000..107a4d7 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowDefinitionValidator.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.flow.application.port; + +import com.organizacion.orchestrator.flow.domain.FlowDefinition; +import com.organizacion.orchestrator.flow.domain.ValidationReport; + +public interface FlowDefinitionValidator { + ValidationReport validate(FlowDefinition definition); +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowPreparation.java b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowPreparation.java new file mode 100644 index 0000000..359e091 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowPreparation.java @@ -0,0 +1,11 @@ +package com.organizacion.orchestrator.flow.application.port; + +import com.organizacion.orchestrator.flow.domain.*; + +public interface FlowPreparation { + FlowDefinition decode(String json); + String canonical(FlowDefinition definition); + String checksum(FlowDefinition definition); + ValidationReport validate(FlowDefinition definition); + FlowDefinition resolve(FlowDefinition definition); +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowStore.java b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowStore.java new file mode 100644 index 0000000..3272bee --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/application/port/FlowStore.java @@ -0,0 +1,24 @@ +package com.organizacion.orchestrator.flow.application.port; + +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.request.domain.RequestType; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.Optional; +import java.util.function.Function; + +/** Every operation on a Session participates in one database transaction. Never retain a Session. */ +public interface FlowStore { + T transaction(Function work); + interface Session { + FlowVersion insert(FlowDefinition definition,String actor); + FlowVersion lock(FlowId id); + FlowVersion update(FlowVersion previous,FlowDefinition definition,FlowVersion.Status status,Optional checksum); + RequestType lockType(String key); + RequestType activate(RequestType previous,FlowId flowId); + java.util.List clearActive(FlowId flowId); + void audit(FlowId flowId,String action,String actor,String correlationId,Optional before,Optional after,ObjectValue detail); + /** Flow then type lock, rechecked in transaction; T08 inserts request/idempotency/audit before commit. */ + FlowVersion assignableFlow(String typeKey); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/FlowDefinition.java b/src/main/java/com/organizacion/orchestrator/flow/domain/FlowDefinition.java new file mode 100644 index 0000000..89848ac --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/FlowDefinition.java @@ -0,0 +1,25 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.decision.domain.DecisionDefinition; +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** DSL configuration, separate from version persistence metadata and API DTOs. */ +public record FlowDefinition(int schemaVersion, String flowKey, int version, String initialState, + Optional dataSchema, Optional initialDataSchema, + List states, List events, List transitions, + List decisions, List tasks) { + public FlowDefinition { + Checks.positive(schemaVersion); Checks.key(flowKey); Checks.positive(version); Checks.key(initialState); + Objects.requireNonNull(dataSchema); Objects.requireNonNull(initialDataSchema); + states = List.copyOf(states); events = List.copyOf(events); transitions = List.copyOf(transitions); + decisions = List.copyOf(decisions); tasks = List.copyOf(tasks); + if (states.isEmpty()) throw new IllegalArgumentException("Flow requires states"); + } + public record State(String key, boolean terminal) { public State { Checks.key(key); } } + public record Event(String key, InlineSchema inputSchema) { + public Event { Checks.key(key); Objects.requireNonNull(inputSchema); } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/FlowVersion.java b/src/main/java/com/organizacion/orchestrator/flow/domain/FlowVersion.java new file mode 100644 index 0000000..2c1587a --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/FlowVersion.java @@ -0,0 +1,23 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** Immutable persisted snapshot. Publishing/retiring transactions belong to application services. */ +public record FlowVersion(FlowId id, FlowDefinition definition, Status status, long revision, + Optional checksum, Instant createdAt, String createdBy, Optional publishedAt) { + public enum Status { DRAFT, PUBLISHED, RETIRED } + public FlowVersion { + Objects.requireNonNull(id); Objects.requireNonNull(definition); Objects.requireNonNull(status); + Checks.nonNegative(revision); Objects.requireNonNull(createdAt); Checks.text(createdBy, 200); + Objects.requireNonNull(checksum); Objects.requireNonNull(publishedAt); + checksum.ifPresent(v -> { if (!v.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid SHA-256"); }); + if (status == Status.DRAFT ? checksum.isPresent() || publishedAt.isPresent() : checksum.isEmpty() || publishedAt.isEmpty()) + throw new IllegalArgumentException("Inconsistent publication metadata"); + } + public boolean assignable() { return status == Status.PUBLISHED; } + public boolean executable() { return status != Status.DRAFT; } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/InlineSchema.java b/src/main/java/com/organizacion/orchestrator/flow/domain/InlineSchema.java new file mode 100644 index 0000000..f694716 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/InlineSchema.java @@ -0,0 +1,11 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.shared.domain.JsonValue; + +/** Immutable boolean/object schema. Allowed keywords and semantics are validated at the boundary/T06. */ +public record InlineSchema(JsonValue value) { + public InlineSchema { + if (!(value instanceof JsonValue.ObjectValue || value instanceof JsonValue.BooleanValue)) + throw new IllegalArgumentException("Schema must be an object or boolean"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/PublicationCatalog.java b/src/main/java/com/organizacion/orchestrator/flow/domain/PublicationCatalog.java new file mode 100644 index 0000000..69da05e --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/PublicationCatalog.java @@ -0,0 +1,37 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.action.domain.ActionCapability; +import java.util.*; + +/** Immutable deployment snapshot: contracts and profiles, never endpoints, secrets or provider health. */ +public record PublicationCatalog(Set roles, Map handlers, + Map integrations, Map documents) { + public PublicationCatalog { + roles = Set.copyOf(roles); handlers = Map.copyOf(handlers); + integrations = Map.copyOf(integrations); documents = Map.copyOf(documents); + } + public record DocumentPolicy(Set mimeTypes, long maxBytes) { + public DocumentPolicy { + mimeTypes = Set.copyOf(mimeTypes); + if (mimeTypes.isEmpty() || maxBytes < 1) throw new IllegalArgumentException("Invalid document policy"); + } + } + public record Contract(InlineSchema input, InlineSchema output, ActionCapability.Effect effect, + Map> profiles, Map defaultProfiles) { + public Contract { + Objects.requireNonNull(input); Objects.requireNonNull(output); Objects.requireNonNull(effect); + var copy = new HashMap>(); + profiles.forEach((k,v) -> copy.put(k, Set.copyOf(v))); profiles = Map.copyOf(copy); + defaultProfiles = Map.copyOf(defaultProfiles); + for (var entry : defaultProfiles.entrySet()) + if (!profiles.getOrDefault(entry.getKey(), Set.of()).contains(entry.getValue())) + throw new IllegalArgumentException("Unknown default profile version"); + } + } + public record Integration(String defaultVersion, Map contracts) { + public Integration { + contracts = Map.copyOf(contracts); + if (!contracts.containsKey(defaultVersion)) throw new IllegalArgumentException("Unknown default contract"); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/TaskDefinition.java b/src/main/java/com/organizacion/orchestrator/flow/domain/TaskDefinition.java new file mode 100644 index 0000000..cb293ee --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/TaskDefinition.java @@ -0,0 +1,29 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public record TaskDefinition(String state, String event, Ui ui, InlineSchema inputSchema) { + public TaskDefinition { + Checks.key(state); Checks.key(event); Objects.requireNonNull(ui); Objects.requireNonNull(inputSchema); + } + public sealed interface Ui { + record Form(String title, List fieldOrder) implements Ui { + public Form { + Checks.text(title, 200); fieldOrder = List.copyOf(fieldOrder); + fieldOrder.forEach(v -> Checks.text(v, Integer.MAX_VALUE)); + } + } + record DocumentUpload(String title, String documentType, Set acceptedMimeTypes, long maxBytes) implements Ui { + public DocumentUpload { + Checks.text(title, 200); Checks.key(documentType); acceptedMimeTypes = Set.copyOf(acceptedMimeTypes); + if (acceptedMimeTypes.isEmpty() || maxBytes < 1 || maxBytes > 104857600) + throw new IllegalArgumentException("Invalid document limits"); + for (String mime : acceptedMimeTypes) + if (!mime.matches("[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+")) throw new IllegalArgumentException("Invalid MIME"); + } + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/Transition.java b/src/main/java/com/organizacion/orchestrator/flow/domain/Transition.java new file mode 100644 index 0000000..0cb467e --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/Transition.java @@ -0,0 +1,37 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.action.domain.ActionDefinition; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.*; + +public record Transition(String id, String from, String event, Set allowedRoles, + Optional guard, List actions, Routing routing, List updates) { + public Transition { + Checks.key(id); Checks.key(from); Checks.key(event); + allowedRoles = Set.copyOf(allowedRoles); allowedRoles.forEach(Checks::key); + if (allowedRoles.isEmpty()) throw new IllegalArgumentException("Roles required"); + Objects.requireNonNull(guard); actions = List.copyOf(actions); + Objects.requireNonNull(routing); updates = List.copyOf(updates); + } + public sealed interface Routing { + record Fixed(String to, String outcome) implements Routing { + public Fixed { Checks.key(to); Checks.key(outcome); } + } + record Decision(String decision, Map destinations) implements Routing { + public Decision { + Checks.key(decision); destinations = Map.copyOf(destinations); + if (destinations.isEmpty()) throw new IllegalArgumentException("Destinations required"); + destinations.forEach((k, v) -> { Checks.key(k); Checks.key(v); }); + } + } + } + /** Pointer is relative to request.data; never to the request resource itself. */ + public record Update(String target, ValueExpression value) { + public Update { + Objects.requireNonNull(target); Objects.requireNonNull(value); + if (!target.matches("^/(?:[^~/]|~[01])+(?:/(?:[^~/]|~[01])*)*$")) + throw new IllegalArgumentException("Invalid data pointer"); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/domain/ValidationReport.java b/src/main/java/com/organizacion/orchestrator/flow/domain/ValidationReport.java new file mode 100644 index 0000000..3c6350f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/domain/ValidationReport.java @@ -0,0 +1,18 @@ +package com.organizacion.orchestrator.flow.domain; + +import com.organizacion.orchestrator.shared.domain.Checks; +import java.util.List; +import java.util.Objects; + +public record ValidationReport(List issues) { + public ValidationReport { issues = List.copyOf(issues); } + public boolean valid() { return issues.stream().noneMatch(i -> i.severity() == Severity.ERROR); } + public enum Severity { ERROR, WARNING } + public record Issue(String pointer, String code, Severity severity, String message) { + public Issue { + Objects.requireNonNull(pointer); + if (!pointer.matches("^(?:/(?:[^~/]|~[01])*)*$")) throw new IllegalArgumentException("Invalid issue pointer"); + Checks.key(code); Objects.requireNonNull(severity); Checks.text(message, 2000); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/CatalogFlowPreparation.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/CatalogFlowPreparation.java new file mode 100644 index 0000000..23cd890 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/CatalogFlowPreparation.java @@ -0,0 +1,15 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.organizacion.orchestrator.flow.application.port.FlowPreparation; +import com.organizacion.orchestrator.flow.domain.*; + +public final class CatalogFlowPreparation implements FlowPreparation { + private final FlowJsonCodec codec; + private final ConfiguredFlowValidator validator; + public CatalogFlowPreparation(FlowJsonCodec codec, ConfiguredFlowValidator validator) {this.codec=codec;this.validator=validator;} + public FlowDefinition decode(String json){return codec.decode(json);} + public String canonical(FlowDefinition definition){return codec.encode(definition);} + public String checksum(FlowDefinition definition){return codec.checksum(definition);} + public ValidationReport validate(FlowDefinition definition){return validator.validate(definition);} + public FlowDefinition resolve(FlowDefinition definition){return validator.resolve(definition);} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/ConfiguredFlowValidator.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/ConfiguredFlowValidator.java new file mode 100644 index 0000000..e79ad77 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/ConfiguredFlowValidator.java @@ -0,0 +1,284 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.*; +import com.networknt.schema.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.flow.application.port.FlowDefinitionValidator; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import java.util.*; +import java.util.function.Function; + +/** Publication validation against an immutable, trusted deployment catalog; never calls providers. */ +public final class ConfiguredFlowValidator implements FlowDefinitionValidator { + private final PublicationCatalog catalog; + private final FlowJsonCodec codec; + private final JsonSchemaFactory schemas = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + private final SchemaValidatorsConfig config = SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build(); + public ConfiguredFlowValidator(PublicationCatalog catalog, FlowJsonCodec codec) { + this.catalog=Objects.requireNonNull(catalog); this.codec=Objects.requireNonNull(codec); + } + private static final class Issues { + final List values=new ArrayList<>(); + void add(String path,String code) { + if(values.size()<1000) values.add(new ValidationReport.Issue(path,code,ValidationReport.Severity.ERROR,code)); + } + ValidationReport report(){return new ValidationReport(values);} + } + @Override public ValidationReport validate(FlowDefinition f) { + var errors=new Issues(); + try {codec.encode(f);} catch(RuntimeException e){errors.add("","INVALID_STRUCTURE_OR_LIMIT");return errors.report();} + if(f.schemaVersion()!=1) errors.add("/schemaVersion","UNSUPPORTED_SCHEMA_VERSION"); + var states=unique(f.states(),FlowDefinition.State::key,"/states",errors); + var events=unique(f.events(),FlowDefinition.Event::key,"/events",errors); + var decisions=unique(f.decisions(),DecisionDefinition::id,"/decisions",errors); + unique(f.transitions(),Transition::id,"/transitions",errors); + if(!states.containsKey(f.initialState())) errors.add("/initialState","UNKNOWN_STATE"); + JsonNode data=f.dataSchema().map(s->codec.tree(s.value())).orElse(NullNode.instance); + if(!"object".equals(data.path("type").asText())) errors.add("/dataSchema","OBJECT_DATA_SCHEMA_REQUIRED"); + checkSchema(data,"/dataSchema",errors); + JsonNode initial=f.initialDataSchema().map(s->codec.tree(s.value())).orElse(data); + inputSchema(initial,"/initialDataSchema",errors); + for(int i=0;i();var reachable=new HashSet();var edges=new HashMap>(); + var protectedPaths=new HashSet(); + for(var t:f.transitions()) for(var u:t.updates()) + if(u.value() instanceof ValueExpression.Path p && providerPath(p.pointer())) protectedPaths.add(u.target()); + for(String target:protectedPaths) if(at(initial,tokens(target))!=null) errors.add("/initialDataSchema","PROVIDER_DATA_ACCEPTED_FROM_CLIENT"); + for(int i=0;i(); + context.put("request",data);context.put("event",event==null?NullNode.instance:codec.tree(event.inputSchema().value())); + context.put("actor",actorSchema());context.put("results",JsonNodeFactory.instance.objectNode().put("type","object").put("additionalProperties",false).set("properties",JsonNodeFactory.instance.objectNode())); + t.guard().ifPresent(g->checkCondition(g,context,p+"/guard",errors)); + unique(t.actions(),ActionDefinition::id,p+"/actions",errors); + var resultKeys=new HashSet(); + for(int j=0;j mappings; + if(a instanceof ActionDefinition.Integration action) { + var c=contract(action); + if(c==null){errors.add(ap,"UNKNOWN_CONTRACT_OR_PROFILE");continue;} + input=codec.tree(c.input().value());output=codec.tree(c.output().value());mappings=action.inputs(); + } else { + var doc=(ActionDefinition.RegisterDocument)a; + if(!catalog.documents().containsKey(doc.documentType()))errors.add(ap,"UNKNOWN_DOCUMENT_TYPE"); + input=codec.tree(handler.inputSchema().value());output=codec.tree(handler.outputSchema().value()); + mappings=Map.of("documentId",doc.documentId()); + } + checkSchema(input,ap+"/inputs",errors);checkSchema(output,ap+"/resultKey",errors); + for(var required:input.path("required"))if(!mappings.containsKey(required.asText()))errors.add(ap+"/inputs","MISSING_ACTION_INPUT"); + mappings.forEach((key,v)->mapping(v,at(input,List.of(key)),context,ap+"/inputs/"+escape(key),errors)); + ((ObjectNode)context.get("results").get("properties")).set(a.resultKey(),output); + } + Set destinations=new HashSet<>(); + if(t.routing() instanceof Transition.Routing.Fixed r)destinations.add(r.to()); + else { + var r=(Transition.Routing.Decision)t.routing();var d=decisions.get(r.decision()); + destinations.addAll(r.destinations().values()); + if(d==null)errors.add(p+"/decision","UNKNOWN_DECISION"); + else { + var outputs=new HashSet();outputs.add(d.defaultOutput());d.rules().forEach(rule->outputs.add(rule.output())); + if(!r.destinations().keySet().equals(outputs))errors.add(p+"/destinations","UNCOVERED_DECISION_OUTPUT"); + d.rules().forEach(rule->checkCondition(rule.when(),context,p+"/decision",errors)); + } + } + for(String dest:destinations)if(!states.containsKey(dest))errors.add(p+"/destinations","UNKNOWN_STATE"); + edges.computeIfAbsent(t.from(),k->new HashSet<>()).addAll(destinations); + var updatePaths=new ArrayList>(); + for(int j=0;jprefix(path,protectedPath)||prefix(protectedPath,path)) + && (!(u.value() instanceof ValueExpression.Path source)||!providerPath(source.pointer()))) + errors.add(up,"PROVIDER_DATA_WRITTEN_FROM_UNTRUSTED_SOURCE"); + } + for(String target:protectedPaths)if(at(context.get("event"),tokens(target))!=null)errors.add(p+"/event","PROVIDER_DATA_ACCEPTED_FROM_CLIENT"); + } + var pending=new ArrayDeque();pending.add(f.initialState()); + while(!pending.isEmpty()){var s=pending.remove();if(reachable.add(s))pending.addAll(edges.getOrDefault(s,Set.of()));} + for(int i=0;i(); + for(int i=0;ipolicy.maxBytes()) + errors.add(p+"/ui","DOCUMENT_POLICY_MISMATCH"); + if(transition==null||transition.actions().stream().noneMatch(a->a instanceof ActionDefinition.RegisterDocument d&&d.documentType().equals(ui.documentType()))) + errors.add(p,"DOCUMENT_ACTION_MISMATCH"); + var input=codec.tree(task.inputSchema().value()); + boolean required=false;for(var field:input.path("required"))if(field.asText().equals("documentId"))required=true; + if(!required||!"uuid".equals(input.at("/properties/documentId/format").asText()) + ||!"string".equals(input.at("/properties/documentId/type").asText())) + errors.add(p+"/inputSchema","DOCUMENT_ID_SCHEMA_REQUIRED"); + if(transition!=null&&transition.actions().stream().noneMatch(a->a instanceof ActionDefinition.RegisterDocument d + &&d.documentType().equals(ui.documentType())&&d.documentId().equals(new ValueExpression.Path("/event/data/documentId")))) + errors.add(p,"DOCUMENT_ID_MAPPING_REQUIRED"); + } + } + return errors.report(); + } + private Map unique(List values,Function id,String path,Issues errors) { + var result=new LinkedHashMap();for(int i=0;inew Transition(t.id(),t.from(),t.event(),t.allowedRoles(),t.guard(), + t.actions().stream().map(a->{ + if(!(a instanceof ActionDefinition.Integration i))return a; + var entry=catalog.integrations().get(i.integrationKey());var c=contract(i); + return (ActionDefinition)new ActionDefinition.Integration(i.id(),i.resultKey(),i.integrationKey(), + Optional.of(i.contractVersion().orElse(entry.defaultVersion())),i.profile(), + i.profile().map(p->i.profileVersion().orElse(c.defaultProfiles().get(p))),i.inputs(),i.retry()); + }).toList(),t.routing(),t.updates())).toList(); + return new FlowDefinition(f.schemaVersion(),f.flowKey(),f.version(),f.initialState(),f.dataSchema(),f.initialDataSchema(), + f.states(),f.events(),transitions,f.decisions(),f.tasks()); + } + private void inputSchema(JsonNode schema,String path,Issues errors) { + checkSchema(schema,path,errors); + if(!"object".equals(schema.path("type").asText())||!schema.path("additionalProperties").isBoolean()||schema.path("additionalProperties").asBoolean()) + errors.add(path,"CLOSED_OBJECT_INPUT_REQUIRED"); + } + private void checkSchema(JsonNode schema,String path,Issues errors) { + try { + // Catalog schemas must not introduce remote reference resolution. + if(hasReference(schema)||!schemas.getSchema(SchemaLocation.of("https://json-schema.org/draft/2020-12/schema"),config).validate(schema).isEmpty()) + errors.add(path,"INVALID_INLINE_SCHEMA"); + else schemas.getSchema(schema,config); + if(inconsistent(schema))errors.add(path,"INCONSISTENT_SCHEMA"); + }catch(RuntimeException e){errors.add(path,"INVALID_INLINE_SCHEMA");} + } + private boolean inconsistent(JsonNode schema){ + if(!schema.isObject())return false; + for(String[] pair:List.of(new String[]{"minimum","maximum"},new String[]{"minLength","maxLength"}, + new String[]{"minItems","maxItems"},new String[]{"minProperties","maxProperties"})) + if(schema.has(pair[0])&&schema.has(pair[1])&&schema.get(pair[0]).decimalValue().compareTo(schema.get(pair[1]).decimalValue())>0)return true; + if(schema.has("const")&&!valid(schema,schema.get("const")))return true; + if(schema.path("additionalProperties").equals(BooleanNode.FALSE)) + for(var key:schema.path("required"))if(!schema.path("properties").has(key.asText()))return true; + for(var key:schema.path("required"))if(inconsistent(schema.path("properties").path(key.asText())))return true; + if(schema.path("minItems").asInt()>0&&inconsistent(schema.path("items")))return true; + for(var child:schema.path("allOf"))if(inconsistent(child))return true; + return false; + } + private static boolean hasReference(JsonNode node) { + if(node.isObject()&&(node.has("$ref")||node.has("$dynamicRef")||node.has("$id")))return true; + for(var child:node)if(hasReference(child))return true;return false; + } + public boolean valid(JsonNode schema,JsonNode data) { + if(schema==null||hasReference(schema))return false; + try{return schemas.getSchema(schema,config).validate(data).isEmpty();}catch(RuntimeException e){return false;} + } + private static List tokens(String pointer){return Arrays.stream(pointer.substring(1).split("/",-1)).map(s->s.replace("~1","/").replace("~0","~")).toList();} + private static String escape(String s){return s.replace("~","~0").replace("/","~1");} + private static boolean providerPath(String pointer){return pointer.equals("/results")||pointer.startsWith("/results/");} + private static boolean prefix(Lista,Listb){return a.size()<=b.size()&&a.equals(b.subList(0,a.size()));} + private static JsonNode at(JsonNode schema,List path) { + if(schema==null||schema.isNull()||schema.equals(BooleanNode.FALSE))return null; + for(String token:path){ + if(schema.equals(BooleanNode.TRUE))return schema; + if("array".equals(schema.path("type").asText())){if(!token.matches("0|[1-9][0-9]*"))return null;schema=schema.path("items");} + else if(schema.path("properties").has(token))schema=schema.path("properties").get(token); + else {var extra=schema.path("additionalProperties");if(extra.equals(BooleanNode.FALSE))return null;schema=extra.isMissingNode()?BooleanNode.TRUE:extra;} + } + return schema; + } + private JsonNode source(ValueExpression value,Map context) { + if(value instanceof ValueExpression.Literal literal) { + JsonNode n=codec.tree(literal.value());return JsonNodeFactory.instance.objectNode().put("type", + n.isObject()?"object":n.isArray()?"array":n.isTextual()?"string":n.isNumber()?(n.decimalValue().stripTrailingZeros().scale()<=0?"integer":"number"):n.isBoolean()?"boolean":"null"); + } + var path=tokens(((ValueExpression.Path)value).pointer()); + return at(context.get(path.getFirst()),path.subList(path.getFirst().equals("request")||path.getFirst().equals("event")?2:1,path.size())); + } + private Set types(JsonNode schema){ + if(schema==null)return Set.of();var t=schema.path("type");if(t.isTextual())return Set.of(t.asText()); + var result=new HashSet();t.forEach(n->result.add(n.asText()));return result; + } + private void mapping(ValueExpression value,JsonNode target,Map context,String path,Issues errors) { + var source=source(value,context); + if(target==null||source==null){errors.add(path,"UNKNOWN_MAPPING_PATH");return;} + if(value instanceof ValueExpression.Literal literal){if(!valid(target,codec.tree(literal.value())))errors.add(path,"INVALID_LITERAL_MAPPING");return;} + var from=types(source);var to=types(target); + if(from.isEmpty()){errors.add(path,"UNKNOWN_MAPPING_TYPE");return;} + if(!to.isEmpty()&&!from.stream().allMatch(t->to.contains(t)||(t.equals("integer")&&to.contains("number"))))errors.add(path,"INCOMPATIBLE_MAPPING_TYPE"); + } + private void checkCondition(Condition condition,Map context,String path,Issues errors) { + try{AstLimits.validate(condition,EvaluationLimits.DEFAULT);}catch(DomainException e){errors.add(path,"AST_LIMIT");return;} + var nodes=new ArrayDeque();nodes.add(condition); + while(!nodes.isEmpty())switch(nodes.remove()){ + case Condition.Not n->nodes.add(n.arg()); + case Condition.Logical l->nodes.addAll(l.args()); + case Condition.Exists e->{if(source(e.value(),context)==null)errors.add(path,"UNKNOWN_CONDITION_PATH");} + case Condition.Compare c->{ + var l=types(source(c.left(),context));var r=types(source(c.right(),context)); + if(l.isEmpty()||r.isEmpty())errors.add(path,"UNKNOWN_CONDITION_TYPE"); + else if(Set.of(Condition.Comparison.GT,Condition.Comparison.GTE,Condition.Comparison.LT,Condition.Comparison.LTE).contains(c.op()) + &&(!Set.of("number","integer").containsAll(l)||!Set.of("number","integer").containsAll(r)))errors.add(path,"NON_NUMERIC_COMPARISON"); + else if(c.op()==Condition.Comparison.IN&&!r.equals(Set.of("array")))errors.add(path,"INVALID_MEMBERSHIP_TYPE"); + else if((c.op()==Condition.Comparison.EQ||c.op()==Condition.Comparison.NEQ) + &&!l.contains("null")&&!r.contains("null")&&!normalized(l).equals(normalized(r))) + errors.add(path,"INCOMPATIBLE_COMPARISON_TYPE"); + if(c.op()==Condition.Comparison.IN&&c.right() instanceof ValueExpression.Literal literal + &&literal.value() instanceof JsonValue.ArrayValue array&&!array.values().isEmpty()){ + try{ + var sample=c.left() instanceof ValueExpression.Literal v?v:new ValueExpression.Literal(array.values().getFirst()); + new ConditionEvaluator().evaluate(new Condition.Compare(Condition.Comparison.IN,sample,c.right()), + new EvaluationContext(JsonValue.ObjectValue.empty(),JsonValue.ObjectValue.empty(),JsonValue.ObjectValue.empty(), + new Actor("validation","validation",Set.of()))); + if(!normalized(l).equals(normalized(types(source(sample,context)))))errors.add(path,"INCOMPATIBLE_MEMBERSHIP_TYPE"); + }catch(DomainException e){errors.add(path,"INVALID_MEMBERSHIP_VALUES");} + } + } + } + } + private static Set normalized(Set types){ + var result=new HashSet();types.forEach(t->result.add(t.equals("integer")?"number":t));return result; + } + private static JsonNode actorSchema(){ + var n=JsonNodeFactory.instance.objectNode().put("type","object").put("additionalProperties",false);var p=n.putObject("properties"); + p.putObject("subject").put("type","string");p.putObject("businessScope").put("type","string");p.putObject("roles").put("type","array").putObject("items").put("type","string");return n; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowAdministrationConfiguration.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowAdministrationConfiguration.java new file mode 100644 index 0000000..44af2f3 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowAdministrationConfiguration.java @@ -0,0 +1,21 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.organizacion.orchestrator.flow.application.FlowAdministration; +import com.organizacion.orchestrator.flow.application.port.*; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.infrastructure.security.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; +import java.nio.file.Path; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.admin.enabled",havingValue="true") +public class FlowAdministrationConfiguration { + @Bean ConfiguredFlowValidator configuredFlowValidator(PublicationCatalog catalog,FlowJsonCodec codec){return new ConfiguredFlowValidator(catalog,codec);} + @Bean FlowPreparation flowPreparation(FlowJsonCodec codec,ConfiguredFlowValidator validator){return new CatalogFlowPreparation(codec,validator);} + @Bean FlowAdministration flowAdministration(FlowStore store,FlowPreparation preparation){return new FlowAdministration(store,preparation);} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowJsonCodec.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowJsonCodec.java new file mode 100644 index 0000000..0c24fd3 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowJsonCodec.java @@ -0,0 +1,213 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import com.networknt.schema.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.Duration; +import java.util.*; +import java.util.function.Function; + +/** Jackson 2 is confined to this schema adapter; domain and MVC use separate representations. */ +public final class FlowJsonCodec { + public static final int MAX_BYTES = 1_048_576; + private final ObjectMapper json; + private final JsonSchema schema; + public FlowJsonCodec() { + var factory = JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(64).maxNumberLength(1000).build()).build(); + json = new ObjectMapper(factory).enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + try (var input = getClass().getResourceAsStream("/schemas/flow-definition.schema.json")) { + if (input == null) throw new IllegalStateException("Missing packaged DSL schema"); + schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(json.readTree(input), + SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + } catch (Exception e) { throw new IllegalStateException("Cannot load DSL schema", e); } + } + public FlowDefinition decode(String source) { + try { + if (source.length() > MAX_BYTES || source.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) + throw new IllegalArgumentException("Definition exceeds byte limit"); + JsonNode n = json.readTree(source); + if (!schema.validate(n).isEmpty()) throw new IllegalArgumentException("Invalid DSL structure"); + canonical(n); // validate numeric budgets before conversion + return flow(n); + } catch (IllegalArgumentException e) { throw e; } + catch (Exception e) { throw new IllegalArgumentException("Invalid definition JSON"); } + } + public String encode(FlowDefinition flow) { + flow.decisions().forEach(d -> AstLimits.validate(d, EvaluationLimits.DEFAULT)); + flow.transitions().forEach(t -> t.guard().ifPresent(g -> AstLimits.validate(g, EvaluationLimits.DEFAULT))); + JsonNode node = tree(flow); + if (!schema.validate(node).isEmpty()) throw new IllegalArgumentException("Invalid DSL structure"); + String text = canonical(node); + if (text.getBytes(StandardCharsets.UTF_8).length > MAX_BYTES) throw new IllegalArgumentException("Definition exceeds byte limit"); + return text; + } + public String checksum(FlowDefinition flow) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(encode(flow).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + /** Deterministic application canonical form, not a claim of RFC 8785/JCS floating-point semantics. */ + public String canonical(JsonNode node) { + if (node.isObject()) { + var keys = new TreeSet(); node.fieldNames().forEachRemaining(keys::add); + var values = new ArrayList(); + for (var key : keys) values.add(quote(key) + ":" + canonical(node.get(key))); + return "{" + String.join(",", values) + "}"; + } + if (node.isArray()) { + var values = new ArrayList(); node.forEach(n -> values.add(canonical(n))); + return "[" + String.join(",", values) + "]"; + } + if (node.isNumber()) { + BigDecimal n = node.decimalValue().stripTrailingZeros(); + if (Math.abs((long)n.scale()) > 1000 || n.precision() > 1000) throw new IllegalArgumentException("Number exceeds budget"); + return n.signum() == 0 ? "0" : n.toPlainString(); + } + return node.isTextual()?quote(node.textValue()):node.toString(); + } + private String quote(String text) { + for(int i=0;i=text.length()||!Character.isLowSurrogate(text.charAt(i)))throw new IllegalArgumentException("Invalid Unicode"); + }else if(Character.isLowSurrogate(c))throw new IllegalArgumentException("Invalid Unicode"); + } + try { return json.writeValueAsString(text); } catch (Exception e) { throw new IllegalArgumentException("Invalid text"); } + } + public JsonNode tree(JsonValue value) { + return switch (value) { + case JsonValue.ObjectValue o -> { var n = json.createObjectNode(); o.fields().forEach((k,v) -> n.set(k, tree(v))); yield n; } + case JsonValue.ArrayValue a -> { var n = json.createArrayNode(); a.values().forEach(v -> n.add(tree(v))); yield n; } + case JsonValue.NumberValue n -> DecimalNode.valueOf(n.value()); + case JsonValue.StringValue s -> TextNode.valueOf(s.value()); + case JsonValue.BooleanValue b -> BooleanNode.valueOf(b.value()); + case JsonValue.NullValue ignored -> NullNode.instance; + }; + } + public JsonValue value(JsonNode n) { + if (n.isObject()) { var m = new HashMap(); n.fields().forEachRemaining(e -> m.put(e.getKey(),value(e.getValue()))); return new JsonValue.ObjectValue(m); } + if (n.isArray()) return new JsonValue.ArrayValue(list(n,this::value)); + if (n.isNumber()) return new JsonValue.NumberValue(n.decimalValue()); + if (n.isTextual()) return new JsonValue.StringValue(n.textValue()); + if (n.isBoolean()) return new JsonValue.BooleanValue(n.booleanValue()); + if (n.isNull()) return JsonValue.NullValue.INSTANCE; + throw new IllegalArgumentException("Invalid JSON value"); + } + private static List list(JsonNode n, Function fn) { + var result = new ArrayList(); n.forEach(v -> result.add(fn.apply(v))); return result; + } + private static Optional opt(JsonNode n,String k) { return n.has(k)? Optional.of(n.get(k).asText()):Optional.empty(); } + private InlineSchema inline(JsonNode n) { return new InlineSchema(value(n)); } + private ValueExpression expression(JsonNode n) { return n.has("path")?new ValueExpression.Path(n.get("path").asText()):new ValueExpression.Literal(value(n.get("literal"))); } + private Condition condition(JsonNode n) { + return switch (n.get("op").asText()) { + case "AND","OR" -> new Condition.Logical(Condition.Junction.valueOf(n.get("op").asText()),list(n.get("args"),this::condition)); + case "NOT" -> new Condition.Not(condition(n.get("arg"))); + case "EXISTS" -> new Condition.Exists((ValueExpression.Path)expression(n.get("value"))); + default -> new Condition.Compare(Condition.Comparison.valueOf(n.get("op").asText()),expression(n.get("left")),expression(n.get("right"))); + }; + } + private ActionDefinition action(JsonNode n) { + String id=n.get("id").asText(), result=n.get("resultKey").asText(); + var r=n.path("retry"); var retry=new RetryPolicy(r.path("maxAttempts").asInt(3), + Duration.ofMillis(r.path("initialBackoffMs").asLong(2000)),Duration.ofMillis(r.path("maxBackoffMs").asLong(4000))); + return switch(n.get("type").asText()) { + case "INVOKE_INTEGRATION" -> { + var inputs=new HashMap(); n.get("inputs").fields().forEachRemaining(e->inputs.put(e.getKey(),expression(e.getValue()))); + yield new ActionDefinition.Integration(id,result,n.get("integrationKey").asText(),opt(n,"contractVersion"),opt(n,"profile"),opt(n,"profileVersion"),inputs,retry); + } + case "REGISTER_DOCUMENT" -> new ActionDefinition.RegisterDocument(id,result,n.get("documentType").asText(),expression(n.get("inputs").get("documentId")),retry); + case "NO_OP" -> new ActionDefinition.NoOp(id,result,n.has("output")?(JsonValue.ObjectValue)value(n.get("output")):JsonValue.ObjectValue.empty()); + default -> throw new IllegalArgumentException("Unknown action"); + }; + } + private FlowDefinition flow(JsonNode n) { + return new FlowDefinition(n.get("schemaVersion").bigIntegerValue().intValueExact(),n.get("flowKey").asText(),n.get("version").bigIntegerValue().intValueExact(),n.get("initialState").asText(), + n.has("dataSchema")?Optional.of(inline(n.get("dataSchema"))):Optional.empty(), + n.has("initialDataSchema")?Optional.of(inline(n.get("initialDataSchema"))):Optional.empty(), + list(n.get("states"),s->new FlowDefinition.State(s.get("key").asText(),s.get("terminal").asBoolean())), + list(n.get("events"),e->new FlowDefinition.Event(e.get("key").asText(),inline(e.get("inputSchema")))), + list(n.get("transitions"),t->{ + Transition.Routing route; + if(t.has("to")) route=new Transition.Routing.Fixed(t.get("to").asText(),t.get("outcome").asText()); + else {var d=new HashMap();t.get("destinations").fields().forEachRemaining(e->d.put(e.getKey(),e.getValue().asText()));route=new Transition.Routing.Decision(t.get("decision").asText(),d);} + return new Transition(t.get("id").asText(),t.get("from").asText(),t.get("event").asText(),Set.copyOf(list(t.get("allowedRoles"),JsonNode::asText)), + t.has("guard")?Optional.of(condition(t.get("guard"))):Optional.empty(),list(t.get("actions"),this::action),route, + list(t.path("updates"),u->new Transition.Update(u.get("target").asText(),expression(u.get("value"))))); + }), + list(n.get("decisions"),d->new DecisionDefinition(d.get("id").asText(),DecisionDefinition.HitPolicy.valueOf(d.get("hitPolicy").asText()), + list(d.get("rules"),r->new DecisionDefinition.Rule(r.get("id").asText(),condition(r.get("when")),r.get("output").asText())),d.get("defaultOutput").asText())), + list(n.get("tasks"),t->{ + var u=t.get("ui"); TaskDefinition.Ui ui=u.get("component").asText().equals("FORM") + ?new TaskDefinition.Ui.Form(u.get("title").asText(),list(u.path("fieldOrder"),JsonNode::asText)) + :new TaskDefinition.Ui.DocumentUpload(u.get("title").asText(),u.get("documentType").asText(),Set.copyOf(list(u.get("acceptedMimeTypes"),JsonNode::asText)),u.get("maxBytes").longValue()); + return new TaskDefinition(t.get("state").asText(),t.get("event").asText(),ui,inline(t.get("inputSchema"))); + })); + } + private ObjectNode object(Object... pairs) { + var node=json.createObjectNode(); + for(int i=0;i source,Function mapper) { + var result=json.createArrayNode(); source.forEach(v->result.add(mapper.apply(v)));return result; + } + private JsonNode expr(ValueExpression value) { + return value instanceof ValueExpression.Path p?object("path",p.pointer()):object("literal",tree(((ValueExpression.Literal)value).value())); + } + private JsonNode cond(Condition c) { + return switch(c) { + case Condition.Compare x -> object("op",x.op().name(),"left",expr(x.left()),"right",expr(x.right())); + case Condition.Exists x -> object("op","EXISTS","value",expr(x.value())); + case Condition.Not x -> object("op","NOT","arg",cond(x.arg())); + case Condition.Logical x -> object("op",x.op().name(),"args",array(x.args(),v->cond((Condition)v))); + }; + } + private void retry(ObjectNode n,RetryPolicy r) {n.set("retry",object("maxAttempts",r.maxAttempts(),"initialBackoffMs",r.initialBackoff().toMillis(),"maxBackoffMs",r.maxBackoff().toMillis()));} + public JsonNode act(ActionDefinition a) { + var n=object("id",a.id(),"type",a.type(),"resultKey",a.resultKey()); + switch(a) { + case ActionDefinition.Integration i -> { + n.put("integrationKey",i.integrationKey()); i.contractVersion().ifPresent(v->n.put("contractVersion",v)); + i.profile().ifPresent(v->n.put("profile",v)); i.profileVersion().ifPresent(v->n.put("profileVersion",v)); + var inputs=json.createObjectNode(); i.inputs().forEach((k,v)->inputs.set(k,expr(v)));n.set("inputs",inputs);retry(n,i.retry()); + } + case ActionDefinition.RegisterDocument d -> {n.put("documentType",d.documentType());n.set("inputs",object("documentId",expr(d.documentId())));retry(n,d.retry());} + case ActionDefinition.NoOp noop -> n.set("output",tree(noop.output())); + } + return n; + } + public JsonNode tree(FlowDefinition f) { + var n=object("schemaVersion",f.schemaVersion(),"flowKey",f.flowKey(),"version",f.version(),"initialState",f.initialState()); + f.dataSchema().ifPresent(s->n.set("dataSchema",tree(s.value()))); f.initialDataSchema().ifPresent(s->n.set("initialDataSchema",tree(s.value()))); + n.set("states",array(f.states(),v->{var s=(FlowDefinition.State)v;return object("key",s.key(),"terminal",s.terminal());})); + n.set("events",array(f.events(),v->{var e=(FlowDefinition.Event)v;return object("key",e.key(),"inputSchema",tree(e.inputSchema().value()));})); + n.set("transitions",array(f.transitions(),v->{ + var t=(Transition)v;var out=object("id",t.id(),"from",t.from(),"event",t.event(),"allowedRoles",new TreeSet<>(t.allowedRoles()), + "actions",array(t.actions(),a->act((ActionDefinition)a)),"updates",array(t.updates(),u->{var x=(Transition.Update)u;return object("target",x.target(),"value",expr(x.value()));})); + t.guard().ifPresent(g->out.set("guard",cond(g))); + if(t.routing() instanceof Transition.Routing.Fixed r){out.put("to",r.to());out.put("outcome",r.outcome());} + else {var r=(Transition.Routing.Decision)t.routing();out.put("decision",r.decision());out.set("destinations",json.valueToTree(r.destinations()));} + return out; + })); + n.set("decisions",array(f.decisions(),v->{var d=(DecisionDefinition)v;return object("id",d.id(),"hitPolicy",d.hitPolicy().name(),"defaultOutput",d.defaultOutput(), + "rules",array(d.rules(),r->{var x=(DecisionDefinition.Rule)r;return object("id",x.id(),"when",cond(x.when()),"output",x.output());}));})); + n.set("tasks",array(f.tasks(),v->{ + var t=(TaskDefinition)v;JsonNode ui; + if(t.ui() instanceof TaskDefinition.Ui.Form form) ui=object("component","FORM","title",form.title(),"fieldOrder",form.fieldOrder()); + else {var d=(TaskDefinition.Ui.DocumentUpload)t.ui();ui=object("component","DOCUMENT_UPLOAD","title",d.title(),"documentType",d.documentType(),"acceptedMimeTypes",new TreeSet<>(d.acceptedMimeTypes()),"maxBytes",d.maxBytes());} + return object("state",t.state(),"event",t.event(),"ui",ui,"inputSchema",tree(t.inputSchema().value())); + })); + return n; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowSimulation.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowSimulation.java new file mode 100644 index 0000000..eb4d9b7 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/FlowSimulation.java @@ -0,0 +1,102 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import com.networknt.schema.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.execution.domain.EventPolicy; +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.execution.infrastructure.TransitionEvaluator; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import java.util.*; + +/** Only supplied fixtures and immutable catalog contracts; no HTTP, worker registry or write port. */ +public final class FlowSimulation { + private final PublicationCatalog catalog;private final FlowJsonCodec codec;private final RequestJson json; + private final JsonSchema inputSchema; + private final JsonSchemaFactory schemas=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + private final SchemaValidatorsConfig config=SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build(); + public FlowSimulation(PublicationCatalog catalog,FlowJsonCodec codec){ + this.catalog=catalog;this.codec=codec;json=new RequestJson(codec); + try(var in=getClass().getResourceAsStream("/schemas/api.schema.json")){ + var api=new ObjectMapper().readTree(in);var root=JsonNodeFactory.instance.objectNode().put("$ref","#/$defs/SimulationInput");root.set("$defs",api.get("$defs"));inputSchema=schemas.getSchema(root,config); + }catch(java.io.IOException e){throw new IllegalStateException("Simulation contract unavailable",e);} + } + public String simulate(FlowVersion version,JsonNode fixture){ + if(!inputSchema.validate(fixture).isEmpty())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + var flow=version.definition();codec.encode(flow); // DSL/AST limits also apply to drafts. + unique(flow.states().stream().map(FlowDefinition.State::key).toList());unique(flow.events().stream().map(FlowDefinition.Event::key).toList()); + unique(flow.transitions().stream().map(Transition::id).toList());unique(flow.decisions().stream().map(DecisionDefinition::id).toList()); + String state=fixture.get("state").asText(),eventKey=fixture.at("/event/event").asText(); + var actorNode=fixture.get("actor");var roles=new HashSet();actorNode.get("roles").forEach(r->roles.add(r.asText())); + var actor=new Actor(actorNode.get("subject").asText(),actorNode.get("businessScope").asText(),roles); + if(flow.states().stream().noneMatch(s->s.key().equals(state)&&!s.terminal()))throw new DomainException(ErrorCode.INVALID_EVENT); + if(flow.transitions().stream().filter(t->t.from().equals(state)&&t.event().equals(eventKey)).count()!=1)throw new DomainException(ErrorCode.INVALID_EVENT); + var policy=new EventPolicy();var transition=policy.transition(flow,state,eventKey,actor); + unique(transition.actions().stream().map(ActionDefinition::id).toList());unique(transition.actions().stream().map(ActionDefinition::resultKey).toList()); + ObjectValue data=object(fixture.get("requestData")),event=object(fixture.at("/event/data"));json.data(data);json.data(event); + validate(flow.dataSchema().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)),data,ErrorCode.SCHEMA_VALIDATION_ERROR); + var eventSchema=flow.events().stream().filter(e->e.key().equals(eventKey)).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_EVENT)); + validate(eventSchema.inputSchema(),event,ErrorCode.SCHEMA_VALIDATION_ERROR);policy.guard(transition,data,event,actor); + var supplied=object(fixture.get("results"));var expected=new HashSet(); + transition.actions().stream().filter(a->!(a instanceof ActionDefinition.NoOp)).forEach(a->expected.add(a.resultKey())); + if(!supplied.fields().keySet().equals(expected))throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + var results=new LinkedHashMap(); + for(var action:transition.actions()){ + if(action instanceof ActionDefinition.NoOp noop){json.data(noop.output());results.put(action.resultKey(),noop.output());continue;} + InlineSchema input,output;Map mappings; + if(action instanceof ActionDefinition.Integration i){ + var entry=catalog.integrations().get(i.integrationKey());if(entry==null)throw new DomainException(ErrorCode.INTEGRATION_UNAVAILABLE); + var contract=entry.contracts().get(i.contractVersion().orElse(entry.defaultVersion())); + if(contract==null)throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + if(i.profile().isPresent()){ + String profileVersion=i.profileVersion().orElse(contract.defaultProfiles().get(i.profile().get())); + if(profileVersion==null||!contract.profiles().getOrDefault(i.profile().get(),Set.of()).contains(profileVersion))throw new DomainException(ErrorCode.INTEGRATION_CONTRACT_ERROR); + } + if(!catalog.handlers().containsKey(i.type()))throw new DomainException(ErrorCode.INTEGRATION_UNAVAILABLE); + input=contract.input();output=contract.output();mappings=i.inputs(); + }else{ + var doc=(ActionDefinition.RegisterDocument)action;var handler=catalog.handlers().get(doc.type()); + if(handler==null||!catalog.documents().containsKey(doc.documentType()))throw new DomainException(ErrorCode.INTEGRATION_UNAVAILABLE); + input=handler.inputSchema();output=handler.outputSchema();mappings=Map.of("documentId",doc.documentId()); + } + var context=new EvaluationContext(data,event,new ObjectValue(results),actor);var mapped=new HashMap(); + mappings.forEach((key,value)->mapped.put(key,resolve(value,context)));validate(input,new ObjectValue(mapped),ErrorCode.INTEGRATION_CONTRACT_ERROR); + ObjectValue result=(ObjectValue)supplied.fields().get(action.resultKey());json.data(result);validate(output,result,ErrorCode.INTEGRATION_CONTRACT_ERROR); + if(action instanceof ActionDefinition.RegisterDocument doc){ + var limits=catalog.documents().get(doc.documentType()); + com.organizacion.orchestrator.document.domain.DocumentMetadata.requireLimits(doc.documentType(),limits.mimeTypes(),limits.maxBytes(),result); + flow.tasks().stream().filter(t->t.state().equals(state)&&t.event().equals(eventKey)).map(TaskDefinition::ui).filter(ui->ui instanceof TaskDefinition.Ui.DocumentUpload) + .map(ui->(TaskDefinition.Ui.DocumentUpload)ui).forEach(ui->com.organizacion.orchestrator.document.domain.DocumentMetadata.requireLimits(doc.documentType(),ui.acceptedMimeTypes(),ui.maxBytes(),result)); + } + results.put(action.resultKey(),result); + } + var zero=new UUID(0,0);var work=new Work(new Token(new ExecutionId(zero),new RequestId(zero),"simulation",0),version.id(),flow,transition,data,event,actor,0); + var completed=new TransitionEvaluator(codec).complete(work,results); + if(flow.states().stream().noneMatch(s->s.key().equals(completed.toState())))throw new DomainException(ErrorCode.INVALID_FLOW); + var response=JsonNodeFactory.instance.objectNode().put("transitionId",transition.id()).put("toState",completed.toState()); + response.set("outcome",codec.tree(completed.result().fields().get("outcome")));response.set("data",codec.tree(completed.data())); + response.set("matchedRuleIds",codec.tree(completed.audit().fields().get("ruleIds")));return codec.canonical(response); + } + private ObjectValue object(JsonNode node){return (ObjectValue)codec.value(node);} + private static void unique(List values){if(new HashSet<>(values).size()!=values.size())throw new DomainException(ErrorCode.INVALID_FLOW);} + private JsonValue resolve(ValueExpression value,EvaluationContext context){ + if(value instanceof ValueExpression.Literal literal)return literal.value(); + var found=new JsonPointerResolver().resolve((ValueExpression.Path)value,context); + if(found instanceof Lookup.Present p)return p.value();throw new DomainException(ErrorCode.EVALUATION_ERROR); + } + private void validate(InlineSchema schema,ObjectValue value,ErrorCode error){ + var tree=codec.tree(schema.value());rejectReferences(tree); + try{if(!schemas.getSchema(tree,config).validate(codec.tree(value)).isEmpty())throw new DomainException(error);} + catch(DomainException e){throw e;}catch(RuntimeException e){throw new DomainException(ErrorCode.INVALID_FLOW);} + } + private void rejectReferences(JsonNode node){ + if(node.isObject()&&(node.has("$ref")||node.has("$dynamicRef")||node.has("$recursiveRef")||node.has("$schema")))throw new DomainException(ErrorCode.INVALID_FLOW); + if(node.isContainerNode())node.forEach(this::rejectReferences); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PostgresFlowStore.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PostgresFlowStore.java new file mode 100644 index 0000000..afcdfa6 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PostgresFlowStore.java @@ -0,0 +1,91 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.request.domain.RequestType; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.dao.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import javax.sql.DataSource; +import java.sql.*; +import java.util.*; +import java.util.function.Function; + +/** Short PostgreSQL transactions with explicit lock order and JSONB writes. No network calls inside. */ +public final class PostgresFlowStore implements FlowStore { + private final JdbcTemplate jdbc;private final TransactionTemplate transactions;private final String schema; + private final FlowJsonCodec codec; + public PostgresFlowStore(DataSource dataSource,FlowJsonCodec codec){this(dataSource,codec,"orchestrator");} + public PostgresFlowStore(DataSource dataSource,FlowJsonCodec codec,String schema){ + if(!schema.matches("orchestrator|t0[678]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + this.schema="\""+schema+"\".";this.codec=codec;this.jdbc=new JdbcTemplate(dataSource);jdbc.setQueryTimeout(15); + var manager=new DataSourceTransactionManager(dataSource);manager.setValidateExistingTransaction(true); + transactions=new TransactionTemplate(manager);transactions.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + transactions.setTimeout(30); + } + @Override public T transaction(Function work){ + try{return transactions.execute(status->{var session=new Tx();try{return work.apply(session);}finally{session.active=false;}});} + catch(DuplicateKeyException e){throw new DomainException(ErrorCode.INVALID_FLOW);} + } + private FlowVersion row(ResultSet r,int ignored)throws SQLException{ + return new FlowVersion(new FlowId(r.getObject("id",UUID.class)),codec.decode(r.getString("definition")), + FlowVersion.Status.valueOf(r.getString("status")),r.getLong("revision"),Optional.ofNullable(r.getString("checksum")), + r.getTimestamp("created_at").toInstant(),r.getString("created_by"),Optional.ofNullable(r.getTimestamp("published_at")).map(Timestamp::toInstant)); + } + private final class Tx implements Session{ + private boolean active=true; + private void check(){if(!active)throw new IllegalStateException("Transaction session already closed");} + private FlowVersion find(FlowId id,boolean lock){ + check();var values=jdbc.query("SELECT * FROM "+schema+"flow_definition WHERE id=?"+(lock?" FOR UPDATE":""),PostgresFlowStore.this::row,id.value()); + if(values.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return values.getFirst(); + } + public FlowVersion insert(FlowDefinition f,String actor){ + check();var id=new FlowId(UUID.randomUUID()); + jdbc.update("INSERT INTO "+schema+"flow_definition(id,flow_key,version,status,schema_version,definition,created_by) VALUES (?,?,?,'DRAFT',?,?::jsonb,?)", + id.value(),f.flowKey(),f.version(),f.schemaVersion(),codec.encode(f),actor);return find(id,false); + } + public FlowVersion lock(FlowId id){return find(id,true);} + public FlowVersion update(FlowVersion previous,FlowDefinition f,FlowVersion.Status status,Optional checksum){ + check();int changed=jdbc.update("UPDATE "+schema+"flow_definition SET definition=?::jsonb,schema_version=?,status=?,checksum=?,revision=revision+1," + +"published_at=CASE WHEN ?='PUBLISHED' THEN coalesce(published_at,now()) ELSE published_at END WHERE id=? AND revision=?", + codec.encode(f),f.schemaVersion(),status.name(),checksum.orElse(null),status.name(),previous.id().value(),previous.revision()); + if(changed!=1)throw new DomainException(ErrorCode.REVISION_CONFLICT);return find(previous.id(),false); + } + public RequestType lockType(String key){ + check();var values=jdbc.query("SELECT * FROM "+schema+"request_type WHERE type_key=? FOR UPDATE",(r,i)-> + new RequestType(r.getString("type_key"),Optional.ofNullable(r.getObject("active_flow_id",UUID.class)).map(FlowId::new),r.getBoolean("enabled"),r.getLong("revision")),key); + if(values.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return values.getFirst(); + } + public RequestType activate(RequestType previous,FlowId id){ + check();int changed=jdbc.update("UPDATE "+schema+"request_type SET active_flow_id=?,revision=revision+1 WHERE type_key=? AND revision=?", + id.value(),previous.typeKey(),previous.revision()); + if(changed!=1)throw new DomainException(ErrorCode.REVISION_CONFLICT);return lockType(previous.typeKey()); + } + public List clearActive(FlowId id){ + check(); + // Deterministic ordering when multiple request types point to this version. + var keys=jdbc.query("SELECT type_key FROM "+schema+"request_type WHERE active_flow_id=? ORDER BY type_key FOR UPDATE", + (r,i)->r.getString(1),id.value()); + jdbc.update("UPDATE "+schema+"request_type SET active_flow_id=NULL,revision=revision+1 WHERE active_flow_id=?",id.value()); + return List.copyOf(keys); + } + public void audit(FlowId id,String action,String actor,String correlation,Optional before,Optional after,ObjectValue detail){ + check();jdbc.update("INSERT INTO "+schema+"admin_audit(id,flow_id,action,actor_subject,correlation_id,previous_checksum,new_checksum,detail)" + +" VALUES (?,?,?,?,?,?,?,?::jsonb)",UUID.randomUUID(),id.value(),action,actor,correlation,before.orElse(null),after.orElse(null),codec.canonical(codec.tree(detail))); + } + public FlowVersion assignableFlow(String key){ + check();var ids=jdbc.query("SELECT active_flow_id FROM "+schema+"request_type WHERE type_key=?",(r,i)->r.getObject(1,UUID.class),key); + if(ids.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND); + if(ids.getFirst()==null)throw new DomainException(ErrorCode.INVALID_FLOW); + var flow=lock(new FlowId(ids.getFirst()));var type=lockType(key); + if(!type.activeFlowId().equals(Optional.of(flow.id())))throw new DomainException(ErrorCode.REVISION_CONFLICT); + if(!type.enabled()||!flow.assignable())throw new DomainException(ErrorCode.INVALID_FLOW); + return flow; + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogConfiguration.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogConfiguration.java new file mode 100644 index 0000000..75f3594 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogConfiguration.java @@ -0,0 +1,13 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.*; +import java.nio.file.Path; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnExpression("${orchestrator.admin.enabled:false} or ${orchestrator.worker.enabled:false}") +public class PublicationCatalogConfiguration { + @Bean PublicationCatalog publicationCatalog(FlowJsonCodec codec,@Value("${orchestrator.flow.catalog-path:config/publication-catalog.json}")String path){return new PublicationCatalogLoader(codec).load(Path.of(path));} +} diff --git a/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogLoader.java b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogLoader.java new file mode 100644 index 0000000..e3b7b2d --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/flow/infrastructure/PublicationCatalogLoader.java @@ -0,0 +1,65 @@ +package com.organizacion.orchestrator.flow.infrastructure; + +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.*; +import com.organizacion.orchestrator.action.domain.ActionCapability; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.shared.domain.Checks; +import java.nio.file.*; +import java.util.*; + +/** Deployment file only; no HTTP endpoint can replace this snapshot. Empty registries grant no capabilities. */ +public final class PublicationCatalogLoader { + private final FlowJsonCodec codec; + public PublicationCatalogLoader(FlowJsonCodec codec){this.codec=codec;} + public PublicationCatalog load(Path path){ + try(var stream=Files.newInputStream(path)){ + byte[] bytes=stream.readNBytes(FlowJsonCodec.MAX_BYTES+1); + if(bytes.length>FlowJsonCodec.MAX_BYTES)throw new IllegalArgumentException("Catalog too large"); + var mapper=new ObjectMapper(com.fasterxml.jackson.core.JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build()) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + return parse(mapper.readTree(bytes)); + }catch(Exception e){throw new IllegalStateException("Invalid publication catalog configuration",e);} + } + public PublicationCatalog parse(JsonNode n){ + fields(n,Set.of("roles","handlers","integrations","documents")); + var roles=strings(n.get("roles"));roles.forEach(Checks::key); + var handlers=new HashMap(); + object(n.get("handlers")).fields().forEachRemaining(e->{ + Checks.key(e.getKey());var h=e.getValue();fields(h,Set.of("effect","configurationSchema","inputSchema","outputSchema")); + handlers.put(e.getKey(),new ActionCapability(e.getKey(),ActionCapability.Effect.valueOf(h.get("effect").asText()), + schema(h.get("configurationSchema")),schema(h.get("inputSchema")),schema(h.get("outputSchema")))); + }); + var integrations=new HashMap(); + object(n.get("integrations")).fields().forEachRemaining(e->{ + Checks.key(e.getKey());var item=e.getValue();fields(item,Set.of("defaultVersion","contracts")); + var contracts=new HashMap(); + object(item.get("contracts")).fields().forEachRemaining(v->{ + Checks.text(v.getKey(),100);var c=v.getValue();fields(c,Set.of("inputSchema","outputSchema","effect","profiles","defaultProfiles")); + var profiles=new HashMap>();object(c.get("profiles")).fields().forEachRemaining(p->profiles.put(Checks.key(p.getKey()),strings(p.getValue()))); + var defaults=new HashMap();object(c.get("defaultProfiles")).fields().forEachRemaining(p->defaults.put(p.getKey(),text(p.getValue()))); + contracts.put(v.getKey(),new PublicationCatalog.Contract(schema(c.get("inputSchema")),schema(c.get("outputSchema")), + ActionCapability.Effect.valueOf(c.get("effect").asText()),profiles,defaults)); + }); + integrations.put(e.getKey(),new PublicationCatalog.Integration(text(item.get("defaultVersion")),contracts)); + }); + var documents=new HashMap(); + object(n.get("documents")).fields().forEachRemaining(e->{ + fields(e.getValue(),Set.of("mimeTypes","maxBytes")); + var size=e.getValue().get("maxBytes");if(!size.isIntegralNumber()||!size.canConvertToLong())throw new IllegalArgumentException("Invalid document size"); + documents.put(Checks.key(e.getKey()),new PublicationCatalog.DocumentPolicy(strings(e.getValue().get("mimeTypes")),size.longValue())); + }); + return new PublicationCatalog(roles,handlers,integrations,documents); + } + private InlineSchema schema(JsonNode n){return new InlineSchema(codec.value(n));} + private static JsonNode object(JsonNode n){if(n==null||!n.isObject())throw new IllegalArgumentException("Object required");return n;} + private static String text(JsonNode n){if(n==null||!n.isTextual())throw new IllegalArgumentException("String required");return Checks.text(n.textValue(),200);} + private static Set strings(JsonNode n){ + if(n==null||!n.isArray())throw new IllegalArgumentException("Array required");var result=new HashSet(); + for(var item:n)if(!result.add(text(item)))throw new IllegalArgumentException("Duplicate entry");return Set.copyOf(result); + } + private static void fields(JsonNode n,Set required){ + object(n);var names=new HashSet();n.fieldNames().forEachRemaining(names::add); + if(!names.equals(required))throw new IllegalArgumentException("Unexpected catalog fields"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/BoundedHttp.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/BoundedHttp.java new file mode 100644 index 0000000..bd9f6c2 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/BoundedHttp.java @@ -0,0 +1,40 @@ +package com.organizacion.orchestrator.integration.infrastructure; + +import java.io.ByteArrayOutputStream; +import java.net.http.*; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +/** Deadline covers headers AND full body. Subscriber cancels before buffering beyond its configured limit. */ +public final class BoundedHttp implements AutoCloseable { + public record Response(int status,HttpHeaders headers,byte[] body){} + private final Map clients=new ConcurrentHashMap<>(); + public Response send(HttpRoutes.Route route,String body,Map headers)throws Exception{ + var client=clients.computeIfAbsent(route.connectMs(),ms->HttpClient.newBuilder().connectTimeout(Duration.ofMillis(ms)).followRedirects(HttpClient.Redirect.NEVER).build()); + var request=HttpRequest.newBuilder(route.uri()).timeout(Duration.ofMillis(route.timeoutMs())).header("Content-Type","application/json") + .header("Accept","application/json").header("Accept-Encoding","identity").POST(HttpRequest.BodyPublishers.ofString(body)); + headers.forEach(request::header);var subscriber=new AtomicReference(); + var future=client.sendAsync(request.build(),info->{var limited=new Limited(route.maxBytes());subscriber.set(limited);return limited;}); + try{var result=future.get(route.timeoutMs(),TimeUnit.MILLISECONDS);return new Response(result.statusCode(),result.headers(),result.body());} + catch(Exception e){future.cancel(true);var limited=subscriber.get();if(limited!=null)limited.cancel();if(e instanceof InterruptedException)Thread.currentThread().interrupt();throw e;} + } + public void close(){clients.values().forEach(HttpClient::shutdownNow);clients.clear();} + static final class Limited implements HttpResponse.BodySubscriber { + private final int maximum;private final ByteArrayOutputStream bytes=new ByteArrayOutputStream();private final CompletableFuture body=new CompletableFuture<>(); + private Flow.Subscription subscription;private boolean cancelled; + Limited(int maximum){this.maximum=maximum;} + public CompletionStage getBody(){return body;} + public synchronized void onSubscribe(Flow.Subscription s){subscription=s;if(cancelled)s.cancel();else s.request(1);} + public synchronized void onNext(List chunks){ + if(cancelled)return; + for(var chunk:chunks){if(chunk.remaining()>maximum-bytes.size()){cancel();return;}byte[] buffer=new byte[chunk.remaining()];chunk.get(buffer);bytes.writeBytes(buffer);} + subscription.request(1); + } + public synchronized void cancel(){cancelled=true;if(subscription!=null)subscription.cancel();body.completeExceptionally(new IllegalArgumentException("Response budget exceeded or cancelled"));} + public synchronized void onError(Throwable error){body.completeExceptionally(error);} + public synchronized void onComplete(){body.complete(bytes.toByteArray());} + } +} diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpIntegrationHandler.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpIntegrationHandler.java new file mode 100644 index 0000000..1110964 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpIntegrationHandler.java @@ -0,0 +1,93 @@ +package com.organizacion.orchestrator.integration.infrastructure; + +import com.organizacion.orchestrator.action.application.port.ActionHandler; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import com.networknt.schema.*; +import java.nio.ByteBuffer; +import java.nio.charset.*; +import java.util.*; +import java.util.function.Function; + +public final class HttpIntegrationHandler implements ActionHandler { + private final io.micrometer.core.instrument.MeterRegistry meters; + private final PublicationCatalog catalog;private final HttpRoutes routes;private final BoundedHttp http;private final FlowJsonCodec codec;private final RequestJson json;private final Function secrets; + public HttpIntegrationHandler(PublicationCatalog catalog,HttpRoutes routes,BoundedHttp http,FlowJsonCodec codec,Function secrets){this(catalog,routes,http,codec,secrets,null);} + public HttpIntegrationHandler(PublicationCatalog catalog,HttpRoutes routes,BoundedHttp http,FlowJsonCodec codec,Function secrets,io.micrometer.core.instrument.MeterRegistry meters){this.catalog=catalog;this.routes=routes;this.http=http;this.codec=codec;json=new RequestJson(codec);this.secrets=secrets;this.meters=meters;} + public String type(){return "INVOKE_INTEGRATION";} + public Optional capability(ActionDefinition.Integration action){ + var entry=catalog.integrations().get(action.integrationKey()); + if(!catalog.handlers().containsKey(type())||entry==null||action.contractVersion().isEmpty())return Optional.empty();var contract=entry.contracts().get(action.contractVersion().get()); + if(contract==null||!validProfile(action,contract)||routes.find(key(action)).isEmpty())return Optional.empty(); + return Optional.of(new ActionCapability(type(),contract.effect(),new InlineSchema(new JsonValue.BooleanValue(true)),contract.input(),contract.output())); + } + private static boolean validProfile(ActionDefinition.Integration a,PublicationCatalog.Contract c){return a.profile().isEmpty()?a.profileVersion().isEmpty():a.profileVersion().isPresent()&&c.profiles().getOrDefault(a.profile().get(),Set.of()).contains(a.profileVersion().get());} + private static HttpRoutes.Key key(ActionDefinition.Integration a){return new HttpRoutes.Key(a.integrationKey(),a.contractVersion().orElse(""),a.profile().orElse(""),a.profileVersion().orElse(""));} + public ActionResult execute(ActionDefinition definition,ActionContext context){ + long start=System.nanoTime();String result="error"; + try{var response=invoke(definition,context);result=response instanceof ActionResult.Completed?"completed":"failed";return response;} + finally{if(meters!=null&&definition instanceof ActionDefinition.Integration action&&capability(action).isPresent()) + meters.timer("orchestrator.integration.duration","integration",action.integrationKey(),"result",result).record(System.nanoTime()-start,java.util.concurrent.TimeUnit.NANOSECONDS);} + } + private ActionResult invoke(ActionDefinition definition,ActionContext context){ + if(!(definition instanceof ActionDefinition.Integration action))return failure(ErrorCode.ACTION_FAILED,ActionResult.FailureKind.PERMANENT); + var capability=capability(action);if(capability.isEmpty())return failure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.PERMANENT); + var cap=capability.get();var route=routes.find(key(action)).orElseThrow(); + try{validate(cap.inputSchema(),context.inputs());}catch(RuntimeException e){return failure(ErrorCode.INTEGRATION_CONTRACT_ERROR,ActionResult.FailureKind.PERMANENT);} + var headers=new HashMap();headers.put("Idempotency-Key",context.externalIdempotencyKey()); + headers.put("X-Correlation-Id",context.executionId().value().toString()); + action.profile().ifPresent(p->headers.put("X-Integration-Profile",p));action.profileVersion().ifPresent(v->headers.put("X-Profile-Version",v)); + if(!route.tokenEnvironment().isEmpty()){ + String secret=secrets.apply(route.tokenEnvironment()); + if(secret==null||secret.isBlank()||secret.contains("\r")||secret.contains("\n"))return failure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.PERMANENT); + headers.put("Authorization","Bearer "+secret); + } + BoundedHttp.Response response; + try{response=http.send(route,json.data(context.inputs()),headers);} + catch(Exception e){ + Throwable cause=e;while(cause.getCause()!=null)cause=cause.getCause(); + if(cause instanceof IllegalArgumentException)return failure(ErrorCode.INTEGRATION_CONTRACT_ERROR,uncertain(cap)); + return failure(ErrorCode.INTEGRATION_UNAVAILABLE,cap.effect()==ActionCapability.Effect.NON_IDEMPOTENT_WRITE?ActionResult.FailureKind.UNKNOWN_EFFECT:ActionResult.FailureKind.TRANSIENT); + } + int status=response.status(); + if(status==404&&route.notFoundOutput().isPresent())return completed(route.notFoundOutput().get(),cap); + if(status<200||status>=300){ + boolean transientStatus=status==429||Set.of(500,502,503,504).contains(status); + var kind=transientStatus?(cap.effect()==ActionCapability.Effect.NON_IDEMPOTENT_WRITE?ActionResult.FailureKind.UNKNOWN_EFFECT:ActionResult.FailureKind.TRANSIENT):ActionResult.FailureKind.PERMANENT; + return new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,kind,transientStatus?retryAfter(response.headers().firstValue("Retry-After"),java.time.Instant.now()):Optional.empty()); + } + try{ + String content=response.headers().firstValue("Content-Type").orElse("").split(";",2)[0].trim().toLowerCase(Locale.ROOT); + if(!(content.equals("application/json")||content.startsWith("application/")&&content.endsWith("+json")) + ||!response.headers().firstValue("Content-Encoding").orElse("identity").equalsIgnoreCase("identity"))throw new IllegalArgumentException("Unexpected representation"); + String text=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(response.body())).toString();var node=json.parse(text); + if(node==null||!node.isObject())throw new IllegalArgumentException("Object required"); + if(!route.outputMappings().isEmpty()){ + var mapped=new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode(); + for(var mapping:route.outputMappings().entrySet()){var value=node.at(mapping.getValue());if(!value.isMissingNode())mapped.set(mapping.getKey(),value);} + node=mapped; + } + return completed((ObjectValue)codec.value(node),cap); + }catch(Exception e){return failure(ErrorCode.INTEGRATION_CONTRACT_ERROR,uncertain(cap));} + } + private ActionResult completed(ObjectValue value,ActionCapability cap){try{validate(cap.outputSchema(),value);json.data(value);return new ActionResult.Completed(value,Optional.empty());}catch(RuntimeException e){return failure(ErrorCode.INTEGRATION_CONTRACT_ERROR,uncertain(cap));}} + private static ActionResult.FailureKind uncertain(ActionCapability cap){return cap.effect()==ActionCapability.Effect.READ_ONLY?ActionResult.FailureKind.PERMANENT:ActionResult.FailureKind.UNKNOWN_EFFECT;} + private static ActionResult failure(ErrorCode code,ActionResult.FailureKind kind){return new ActionResult.TechnicalFailure(code,kind);} + public static Optional retryAfter(Optional header,java.time.Instant now){ + if(header.isEmpty())return Optional.empty(); + try{ + String value=header.get().trim();long seconds; + if(value.matches("[0-9]+"))seconds=new java.math.BigInteger(value).min(java.math.BigInteger.valueOf(120)).longValue(); + else seconds=Math.max(0,java.time.Duration.between(now,java.time.ZonedDateTime.parse(value,java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()).getSeconds()); + return Optional.of(java.time.Duration.ofSeconds(Math.min(120,seconds))); + }catch(RuntimeException e){return Optional.empty();} + } + private void validate(InlineSchema schema,ObjectValue value){ + var compiled=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(schema.value()),SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + if(!compiled.validate(codec.tree(value)).isEmpty())throw new IllegalArgumentException("Contract mismatch"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java new file mode 100644 index 0000000..816e522 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/HttpRoutes.java @@ -0,0 +1,71 @@ +package com.organizacion.orchestrator.integration.infrastructure; + +import com.fasterxml.jackson.databind.*; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.net.URI; +import java.nio.file.*; +import java.util.*; + +/** Deployment-only allowlist. Each route is bound to an exact published contract/profile tuple. */ +public final class HttpRoutes { + public record Key(String integration,String version,String profile,String profileVersion){} + public record Route(Key key,URI uri,String tokenEnvironment,int connectMs,int timeoutMs,int maxBytes, + Map outputMappings,Optional notFoundOutput){ + public Route{outputMappings=Map.copyOf(outputMappings);Objects.requireNonNull(notFoundOutput);} + } + private final Map routes; + public HttpRoutes(Collection routes,PublicationCatalog catalog,boolean local,long leaseSeconds){ + var copy=new HashMap(); + for(var route:routes){ + var key=route.key();Checks.key(key.integration());Checks.text(key.version(),100); + var entry=catalog.integrations().get(key.integration());var contract=entry==null?null:entry.contracts().get(key.version()); + if(contract==null||(!key.profile().isEmpty()&&!contract.profiles().getOrDefault(key.profile(),Set.of()).contains(key.profileVersion())) + ||(key.profile().isEmpty()!=key.profileVersion().isEmpty()))throw new IllegalArgumentException("Unknown route contract/profile"); + if(!key.profile().isEmpty())Checks.key(key.profile()); + if(!key.profileVersion().matches("[A-Za-z0-9._-]{0,100}"))throw new IllegalArgumentException("Invalid profile version"); + rejectReferences(new FlowJsonCodec().tree(contract.input().value())); + 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)"); + 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"); + route.outputMappings().forEach((target,pointer)->{Checks.key(target);if(!pointer.matches("(?:/(?:[^~/]|~[01])*)*"))throw new IllegalArgumentException("Invalid output pointer");}); + if(route.notFoundOutput().isPresent()&&contract.effect()!=com.organizacion.orchestrator.action.domain.ActionCapability.Effect.READ_ONLY)throw new IllegalArgumentException("404 outcome only allowed for read-only contracts"); + if(copy.put(key,route)!=null)throw new IllegalArgumentException("Duplicate route"); + } + this.routes=Map.copyOf(copy); + } + public Optional find(Key key){return Optional.ofNullable(routes.get(key));} + private static void rejectReferences(JsonNode node){ + if(node.isObject()&&(node.has("$ref")||node.has("$dynamicRef")))throw new IllegalArgumentException("Inline schemas required"); + if(node.isContainerNode())node.forEach(HttpRoutes::rejectReferences); + } + public static HttpRoutes load(Path path,PublicationCatalog catalog,FlowJsonCodec codec,boolean local,long lease){ + 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)); + fields(n,Set.of("routes"));if(!n.get("routes").isArray())throw new IllegalArgumentException("Routes array required"); + var routes=new ArrayList(); + for(var r:n.get("routes")){ + fields(r,Set.of("integrationKey","contractVersion","profile","profileVersion","url","tokenEnvironment","connectMs","timeoutMs","maxBytes","outputMappings","notFoundOutput")); + var mappings=new HashMap();if(!r.get("outputMappings").isObject())throw new IllegalArgumentException("Mappings object required"); + r.get("outputMappings").fields().forEachRemaining(e->mappings.put(e.getKey(),string(e.getValue()))); + var notFound=r.get("notFoundOutput");if(!notFound.isNull()&&!notFound.isObject())throw new IllegalArgumentException("404 object required"); + routes.add(new Route(new Key(string(r.get("integrationKey")),string(r.get("contractVersion")),string(r.get("profile")),string(r.get("profileVersion"))), + 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); + }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();} + private static String string(JsonNode n){if(!n.isTextual())throw new IllegalArgumentException("String required");return n.textValue();} + private static void fields(JsonNode n,Set names){if(n==null||!n.isObject())throw new IllegalArgumentException("Object required");var actual=new HashSet();n.fieldNames().forEachRemaining(actual::add);if(!actual.equals(names))throw new IllegalArgumentException("Unexpected route fields");} +} diff --git a/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java new file mode 100644 index 0000000..bb99d6f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/integration/infrastructure/IntegrationConfiguration.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator.integration.infrastructure; + +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.execution.application.port.WorkerActions; +import com.organizacion.orchestrator.flow.domain.PublicationCatalog; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import org.springframework.core.env.Environment; +import java.nio.file.Path; +import java.util.Arrays; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.worker.enabled",havingValue="true") +public class IntegrationConfiguration { + @Bean(destroyMethod="close") BoundedHttp boundedHttp(){return new BoundedHttp();} + @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); + } + @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);} + @Bean WorkerActions registeredWorkerActions(HttpIntegrationHandler integrations,RegisterDocumentHandler documents){return new RegisteredWorkerActions(integrations,documents);} +} diff --git a/src/main/java/com/organizacion/orchestrator/request/api/AvailableEventsController.java b/src/main/java/com/organizacion/orchestrator/request/api/AvailableEventsController.java new file mode 100644 index 0000000..7e7d3d8 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/api/AvailableEventsController.java @@ -0,0 +1,36 @@ +package com.organizacion.orchestrator.request.api; +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.request.application.AvailableEvents; +import com.organizacion.orchestrator.flow.domain.TaskDefinition; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.util.*; + +@RestController +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class AvailableEventsController { + private final AvailableEvents events;private final JwtActorMapper actors;private final FlowJsonCodec codec; + public AvailableEventsController(AvailableEvents events,JwtActorMapper actors,FlowJsonCodec codec){this.events=events;this.actors=actors;this.codec=codec;} + @GetMapping(value="/api/v1/requests/{id}/available-events",produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + var view=events.get(id,actors.actor(jwt));var root=JsonNodeFactory.instance.objectNode().put("requestId",id.toString()).put("revision",view.revision()); + var available=root.putArray("events"); + for(var event:view.events()){ + var item=available.addObject().put("event",event.event());item.set("inputSchema",codec.tree(event.inputSchema().value())); + event.ui().ifPresent(ui->{ + var node=item.putObject("ui"); + if(ui instanceof TaskDefinition.Ui.Form form){node.put("component","FORM").put("title",form.title());var order=node.putArray("fieldOrder");form.fieldOrder().forEach(order::add);} + else{var document=(TaskDefinition.Ui.DocumentUpload)ui;node.put("component","DOCUMENT_UPLOAD").put("title",document.title()).put("documentType",document.documentType()).put("maxBytes",document.maxBytes()); + var mime=node.putArray("acceptedMimeTypes");new TreeSet<>(document.acceptedMimeTypes()).forEach(mime::add);} + }); + } + var blocked=root.putArray("blocked");view.blocked().forEach(b->blocked.addObject().put("event",b.event()).put("reasonCode",b.reasonCode())); + view.blockedByExecutionId().ifPresent(value->root.put("blockedByExecutionId",value.toString())); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_JSON).body(codec.canonical(root)); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/api/RequestController.java b/src/main/java/com/organizacion/orchestrator/request/api/RequestController.java new file mode 100644 index 0000000..bdb872d --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/api/RequestController.java @@ -0,0 +1,47 @@ +package com.organizacion.orchestrator.request.api; + +import com.organizacion.orchestrator.request.application.Requests; +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.api.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.*; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/requests") +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class RequestController { + private final Requests requests;private final JwtActorMapper actors;private final FlowJsonCodec codec;private final RequestJson json; + public RequestController(Requests requests,JwtActorMapper actors,FlowJsonCodec codec){this.requests=requests;this.actors=actors;this.codec=codec;json=new RequestJson(codec);} + @PostMapping(consumes=MediaType.APPLICATION_JSON_VALUE,produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(@AuthenticationPrincipal Jwt jwt,HttpServletRequest request)throws IOException{ + var keys=request.getHeaders("Idempotency-Key"); + if(!keys.hasMoreElements())throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY");String key=keys.nextElement(); + if(keys.hasMoreElements()||!key.matches("[!-~]{1,200}"))throw new ApiException(400,"INVALID_IDEMPOTENCY_KEY"); + if(request.getContentLengthLong()>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + var bytes=request.getInputStream().readNBytes(RequestJson.MAX_BODY_BYTES+1); + if(bytes.length>RequestJson.MAX_BODY_BYTES)throw new ApiException(413,"PAYLOAD_TOO_LARGE"); + String source; + try{source=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString();} + catch(CharacterCodingException e){throw new ApiException(400,"INVALID_REQUEST");} + var command=json.command(source); + var snapshot=requests.create(command.typeKey(),command.data(),key,actors.actor(jwt),CorrelationFilter.id(request)); + return ResponseEntity.created(URI.create("/api/v1/requests/"+snapshot.request().id().value())) + .eTag(Long.toString(snapshot.request().revision())).body(RequestView.from(snapshot,codec)); + } + @GetMapping(value="/{id}",produces=MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity get(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + var result=requests.get(new RequestId(id),actors.actor(jwt));return ResponseEntity.ok().eTag(Long.toString(result.request().revision())).body(RequestView.from(result,codec)); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/api/RequestView.java b/src/main/java/com/organizacion/orchestrator/request/api/RequestView.java new file mode 100644 index 0000000..01d7331 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/api/RequestView.java @@ -0,0 +1,19 @@ +package com.organizacion.orchestrator.request.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.domain.RequestSnapshot; +import java.time.Instant; +import java.util.*; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RequestView(UUID id,String typeKey,FlowRef flow,String currentState,Map data,long revision, + UUID activeExecutionId,Instant createdAt,Instant updatedAt){ + public record FlowRef(UUID id,String flowKey,int version){} + private static final ObjectMapper JSON=new ObjectMapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + @SuppressWarnings("unchecked") public static RequestView from(RequestSnapshot snapshot,FlowJsonCodec codec){ + var r=snapshot.request();return new RequestView(r.id().value(),r.typeKey(),new FlowRef(r.flowId().value(),snapshot.flowKey(),snapshot.flowVersion()), + r.currentState(),JSON.convertValue(codec.tree(r.data()),Map.class),r.revision(),snapshot.activeExecutionId().map(v->v.value()).orElse(null),r.createdAt(),r.updatedAt()); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/application/AvailableEvents.java b/src/main/java/com/organizacion/orchestrator/request/application/AvailableEvents.java new file mode 100644 index 0000000..9ab5c6f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/application/AvailableEvents.java @@ -0,0 +1,16 @@ +package com.organizacion.orchestrator.request.application; +import com.organizacion.orchestrator.request.application.port.AvailabilityStore; +import com.organizacion.orchestrator.request.domain.*; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore; +import com.organizacion.orchestrator.shared.domain.Actor; +import java.util.UUID; + +public final class AvailableEvents { + private final AvailabilityStore store;private final ResourceAccess access; + public AvailableEvents(AvailabilityStore store,ResourceAccess access){this.store=store;this.access=access;} + public AvailableEventsView get(UUID id,Actor actor){ + access.require(actor,ResourceOwnershipStore.Kind.REQUEST,id); + return new AvailableEventPolicy().project(store.get(id,actor),actor); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/application/Requests.java b/src/main/java/com/organizacion/orchestrator/request/application/Requests.java new file mode 100644 index 0000000..1fa1dd6 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/application/Requests.java @@ -0,0 +1,22 @@ +package com.organizacion.orchestrator.request.application; + +import com.organizacion.orchestrator.request.application.port.RequestStore; +import com.organizacion.orchestrator.request.domain.RequestSnapshot; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; + +public final class Requests { + private final RequestStore store;private final ResourceAccess access; + public Requests(RequestStore store,ResourceAccess access){this.store=store;this.access=access;} + public RequestSnapshot create(String typeKey,ObjectValue data,String key,Actor actor,String correlation){ + Checks.key(typeKey);Checks.text(correlation,200); + if(key==null||!key.matches("[!-~]{1,200}"))throw new IllegalArgumentException("Invalid idempotency key"); + return store.create(typeKey,data,key,actor,correlation); + } + public RequestSnapshot get(RequestId id,Actor actor){ + access.require(actor,Kind.REQUEST,id.value());return store.get(id,actor); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/application/port/AvailabilityStore.java b/src/main/java/com/organizacion/orchestrator/request/application/port/AvailabilityStore.java new file mode 100644 index 0000000..d47eac6 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/application/port/AvailabilityStore.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.request.application.port; +import com.organizacion.orchestrator.request.domain.AvailabilitySnapshot; +import com.organizacion.orchestrator.shared.domain.Actor; +import java.util.UUID; + +public interface AvailabilityStore { + AvailabilitySnapshot get(UUID requestId,Actor actor); +} diff --git a/src/main/java/com/organizacion/orchestrator/request/application/port/RequestStore.java b/src/main/java/com/organizacion/orchestrator/request/application/port/RequestStore.java new file mode 100644 index 0000000..dd5f498 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/application/port/RequestStore.java @@ -0,0 +1,12 @@ +package com.organizacion.orchestrator.request.application.port; + +import com.organizacion.orchestrator.request.domain.RequestSnapshot; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; + +public interface RequestStore { + /** Atomically resolve/lock active flow, validate, create, audit and save original idempotent response. */ + RequestSnapshot create(String typeKey,ObjectValue data,String key,Actor actor,String correlationId); + RequestSnapshot get(RequestId id,Actor actor); +} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/AvailabilitySnapshot.java b/src/main/java/com/organizacion/orchestrator/request/domain/AvailabilitySnapshot.java new file mode 100644 index 0000000..de8adb2 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/AvailabilitySnapshot.java @@ -0,0 +1,7 @@ +package com.organizacion.orchestrator.request.domain; +import com.organizacion.orchestrator.flow.domain.FlowDefinition; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.*; + +/** Request, pinned flow and active execution observed in one database snapshot. */ +public record AvailabilitySnapshot(UUID requestId,long revision,String state,ObjectValue data,FlowDefinition flow,Optional activeExecution){} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventPolicy.java b/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventPolicy.java new file mode 100644 index 0000000..0f90139 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventPolicy.java @@ -0,0 +1,35 @@ +package com.organizacion.orchestrator.request.domain; + +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.util.*; + +/** Advisory only: a guard needing event input is deferred until POST, never evaluated with invented input. */ +public final class AvailableEventPolicy { + public AvailableEventsView project(AvailabilitySnapshot snapshot,Actor actor){ + var events=new ArrayList();var blocked=new ArrayList(); + if(snapshot.activeExecution().isPresent())return new AvailableEventsView(snapshot.requestId(),snapshot.revision(),events,blocked,snapshot.activeExecution()); + for(var transition:snapshot.flow().transitions()){ + if(!transition.from().equals(snapshot.state())||transition.allowedRoles().stream().noneMatch(actor.roles()::contains))continue; + if(transition.guard().isPresent()&&!needsInput(transition.guard().get())){ + try{ + if(!new ConditionEvaluator().evaluate(transition.guard().get(),new EvaluationContext(snapshot.data(),ObjectValue.empty(),ObjectValue.empty(),actor))){ + blocked.add(new AvailableEventsView.Blocked(transition.event(),"GUARD_NOT_SATISFIED"));continue; + } + }catch(DomainException e){blocked.add(new AvailableEventsView.Blocked(transition.event(),"EVALUATION_ERROR"));continue;} + } + var definition=snapshot.flow().events().stream().filter(e->e.key().equals(transition.event())).findFirst().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + var ui=snapshot.flow().tasks().stream().filter(t->t.state().equals(snapshot.state())&&t.event().equals(transition.event())).map(t->t.ui()).findFirst(); + events.add(new AvailableEventsView.Event(transition.event(),definition.inputSchema(),ui)); + } + return new AvailableEventsView(snapshot.requestId(),snapshot.revision(),events,blocked,Optional.empty()); + } + private static boolean needsInput(ValueExpression value){return value instanceof ValueExpression.Path p&&p.pointer().startsWith("/event/data");} + private static boolean needsInput(Condition condition){ + if(condition instanceof Condition.Compare c)return needsInput(c.left())||needsInput(c.right()); + if(condition instanceof Condition.Exists e)return needsInput(e.value()); + if(condition instanceof Condition.Not n)return needsInput(n.arg()); + return ((Condition.Logical)condition).args().stream().anyMatch(AvailableEventPolicy::needsInput); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventsView.java b/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventsView.java new file mode 100644 index 0000000..b6f4fee --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/AvailableEventsView.java @@ -0,0 +1,10 @@ +package com.organizacion.orchestrator.request.domain; + +import com.organizacion.orchestrator.flow.domain.*; +import java.util.*; + +public record AvailableEventsView(UUID requestId,long revision,List events,List blocked,Optional blockedByExecutionId) { + public AvailableEventsView {events=List.copyOf(events);blocked=List.copyOf(blocked);Objects.requireNonNull(blockedByExecutionId);} + public record Event(String event,InlineSchema inputSchema,Optional ui){} + public record Blocked(String event,String reasonCode){} +} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/RequestInstance.java b/src/main/java/com/organizacion/orchestrator/request/domain/RequestInstance.java new file mode 100644 index 0000000..334de8c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/RequestInstance.java @@ -0,0 +1,18 @@ +package com.organizacion.orchestrator.request.domain; + +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.time.Instant; +import java.util.Objects; + +/** flowId is pinned at creation. No duplicate flow version and no arbitrary mutation API. */ +public record RequestInstance(RequestId id, String typeKey, FlowId flowId, String currentState, + ObjectValue data, String ownerSubject, String businessScope, long revision, + Instant createdAt, Instant updatedAt) { + public RequestInstance { + Objects.requireNonNull(id); Checks.key(typeKey); Objects.requireNonNull(flowId); Checks.key(currentState); + Objects.requireNonNull(data); Checks.text(ownerSubject, 200); Checks.text(businessScope, 100); + Checks.nonNegative(revision); Objects.requireNonNull(createdAt); Objects.requireNonNull(updatedAt); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/RequestSnapshot.java b/src/main/java/com/organizacion/orchestrator/request/domain/RequestSnapshot.java new file mode 100644 index 0000000..63cc45d --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/RequestSnapshot.java @@ -0,0 +1,7 @@ +package com.organizacion.orchestrator.request.domain; + +import java.util.Optional; +import com.organizacion.orchestrator.shared.domain.Identifiers.ExecutionId; + +/** One database snapshot; flow version is read from the pinned flow, never duplicated in request_instance. */ +public record RequestSnapshot(RequestInstance request,String flowKey,int flowVersion,Optional activeExecutionId) {} diff --git a/src/main/java/com/organizacion/orchestrator/request/domain/RequestType.java b/src/main/java/com/organizacion/orchestrator/request/domain/RequestType.java new file mode 100644 index 0000000..cc27f24 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/domain/RequestType.java @@ -0,0 +1,10 @@ +package com.organizacion.orchestrator.request.domain; + +import com.organizacion.orchestrator.shared.domain.Checks; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import java.util.Objects; +import java.util.Optional; + +public record RequestType(String typeKey, Optional activeFlowId, boolean enabled, long revision) { + public RequestType { Checks.key(typeKey); Objects.requireNonNull(activeFlowId); Checks.nonNegative(revision); } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresAvailabilityStore.java b/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresAvailabilityStore.java new file mode 100644 index 0000000..9d48b2c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresAvailabilityStore.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator.request.infrastructure; +import com.organizacion.orchestrator.request.application.port.AvailabilityStore; +import com.organizacion.orchestrator.request.domain.AvailabilitySnapshot; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; +import java.util.*; + +public final class PostgresAvailabilityStore implements AvailabilityStore { + private final JdbcTemplate jdbc;private final String s;private final FlowJsonCodec codec;private final RequestJson json; + public PostgresAvailabilityStore(DataSource source,String schema,FlowJsonCodec codec){ + if(!schema.matches("orchestrator|t07_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + s="\""+schema+"\".";jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15);this.codec=codec;json=new RequestJson(codec); + } + public AvailabilitySnapshot get(UUID id,Actor actor){ + var rows=jdbc.query("SELECT r.id,r.revision,r.current_state,r.data::text,f.definition::text," + +"(SELECT e.id FROM "+s+"execution e WHERE e.request_id=r.id AND e.status IN ('PENDING','RUNNING','RETRY_WAIT','RECONCILIATION_REQUIRED')) AS active_execution " + +"FROM "+s+"request_instance r JOIN "+s+"flow_definition f ON f.id=r.flow_id WHERE r.id=? AND r.owner_subject=? AND r.business_scope=?", + (r,i)->new AvailabilitySnapshot(r.getObject("id",UUID.class),r.getLong("revision"),r.getString("current_state"), + (ObjectValue)codec.value(json.parse(r.getString("data"))),codec.decode(r.getString("definition")),Optional.ofNullable(r.getObject("active_execution",UUID.class))), + id,actor.subject(),actor.businessScope()); + if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst(); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresRequestStore.java b/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresRequestStore.java new file mode 100644 index 0000000..37b6e54 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/infrastructure/PostgresRequestStore.java @@ -0,0 +1,70 @@ +package com.organizacion.orchestrator.request.infrastructure; + +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.application.port.RequestStore; +import com.organizacion.orchestrator.request.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import javax.sql.DataSource; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.*; + +public final class PostgresRequestStore implements RequestStore { + private final JdbcTemplate jdbc;private final TransactionTemplate tx;private final String schema; + private final FlowStore flows;private final RequestJson json;private final FlowJsonCodec codec; + public PostgresRequestStore(DataSource source,FlowStore flows,FlowJsonCodec codec,String schema){ + if(!schema.matches("orchestrator|t0[78]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + this.schema="\""+schema+"\".";this.flows=flows;this.codec=codec;json=new RequestJson(codec);jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(15); + var manager=new DataSourceTransactionManager(source);manager.setValidateExistingTransaction(true);tx=new TransactionTemplate(manager); + tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);tx.setTimeout(30); + } + private static byte[] digest(String value){try{return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + public RequestSnapshot create(String type,ObjectValue data,String key,Actor actor,String correlation){ + String encoded=json.data(data),hash=HexFormat.of().formatHex(digest(json.command(type,data))); + // Length framing avoids ambiguity. Advisory collision can only serialize unrelated commands, never replay them. + String identity=schema+":"+actor.subject().length()+":"+actor.subject()+actor.businessScope().length()+":"+actor.businessScope()+key.length()+":"+key; + return tx.execute(status->{ + jdbc.queryForObject("SELECT pg_advisory_xact_lock(?)",Object.class,ByteBuffer.wrap(digest(identity)).getLong()); + var replay=jdbc.query("SELECT payload_hash,response::text FROM "+schema+"request_creation_idempotency WHERE actor_subject=? AND business_scope=? AND idempotency_key=?", + (r,i)->new String[]{r.getString(1),r.getString(2)},actor.subject(),actor.businessScope(),key); + if(!replay.isEmpty()){ + if(!replay.getFirst()[0].equals(hash))throw new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT); + var original=json.snapshot(replay.getFirst()[1],actor);get(original.request().id(),actor);return original; + } + return flows.transaction(session->{ + com.organizacion.orchestrator.flow.domain.FlowVersion flow; + try{flow=session.assignableFlow(type);}catch(DomainException e){ + if(e.code()==ErrorCode.REVISION_CONFLICT)throw new DomainException(ErrorCode.INVALID_FLOW);throw e; + } + json.validate(flow.definition(),data);var id=new RequestId(UUID.randomUUID()); + jdbc.update("INSERT INTO "+schema+"request_instance(id,type_key,flow_id,current_state,data,owner_subject,business_scope) VALUES (?,?,?,?,?::jsonb,?,?)", + id.value(),type,flow.id().value(),flow.definition().initialState(),encoded,actor.subject(),actor.businessScope()); + var result=get(id,actor); + var detail=new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode().put("correlationId",correlation); + jdbc.update("INSERT INTO "+schema+"audit_entry(id,request_id,kind,actor_subject,flow_id,to_state,detail) VALUES (?,?,'REQUEST_CREATED',?,?,?,?::jsonb)", + UUID.randomUUID(),id.value(),actor.subject(),flow.id().value(),flow.definition().initialState(),codec.canonical(detail)); + jdbc.update("INSERT INTO "+schema+"request_creation_idempotency(id,actor_subject,business_scope,idempotency_key,payload_hash,request_id,response) VALUES (?,?,?,?,?,?,?::jsonb)", + UUID.randomUUID(),actor.subject(),actor.businessScope(),key,hash,id.value(),json.snapshot(result)); + return result; + }); + }); + } + public RequestSnapshot get(RequestId id,Actor actor){ + var rows=jdbc.query("SELECT r.*,f.flow_key,f.version,e.id AS active_id FROM "+schema+"request_instance r JOIN "+schema+"flow_definition f ON f.id=r.flow_id " + +"LEFT JOIN "+schema+"execution e ON e.request_id=r.id AND e.status IN ('PENDING','RUNNING','RETRY_WAIT','RECONCILIATION_REQUIRED') " + +"WHERE r.id=? AND r.owner_subject=? AND r.business_scope=?",(r,i)->new RequestSnapshot( + new RequestInstance(new RequestId(r.getObject("id",UUID.class)),r.getString("type_key"),new FlowId(r.getObject("flow_id",UUID.class)), + r.getString("current_state"),(ObjectValue)codec.value(json.parse(r.getString("data"))),r.getString("owner_subject"),r.getString("business_scope"), + r.getLong("revision"),r.getTimestamp("created_at").toInstant(),r.getTimestamp("updated_at").toInstant()), + r.getString("flow_key"),r.getInt("version"),Optional.ofNullable(r.getObject("active_id",UUID.class)).map(ExecutionId::new)),id.value(),actor.subject(),actor.businessScope()); + if(rows.isEmpty())throw new DomainException(ErrorCode.RESOURCE_NOT_FOUND);return rows.getFirst(); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestConfiguration.java b/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestConfiguration.java new file mode 100644 index 0000000..2e755a8 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestConfiguration.java @@ -0,0 +1,24 @@ +package com.organizacion.orchestrator.request.infrastructure; + +import com.organizacion.orchestrator.request.application.Requests; +import com.organizacion.orchestrator.request.application.port.RequestStore; +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="orchestrator.requests.enabled",havingValue="true") +public class RequestConfiguration { + @Bean RequestStore requestStore(DataSource source,FlowStore flows,FlowJsonCodec codec,RuntimeRoleVerifier verified, + @Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresRequestStore(source,flows,codec,schema);} + @Bean Requests requests(RequestStore store,ResourceAccess access){return new Requests(store,access);} + @Bean com.organizacion.orchestrator.request.application.AvailableEvents availableEvents(DataSource source,FlowJsonCodec codec,ResourceAccess access,RuntimeRoleVerifier verified, + @Value("${orchestrator.database.schema:orchestrator}")String schema){ + return new com.organizacion.orchestrator.request.application.AvailableEvents(new PostgresAvailabilityStore(source,schema,codec),access); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestJson.java b/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestJson.java new file mode 100644 index 0000000..71fe71d --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/request/infrastructure/RequestJson.java @@ -0,0 +1,60 @@ +package com.organizacion.orchestrator.request.infrastructure; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import com.networknt.schema.*; +import com.organizacion.orchestrator.flow.domain.FlowDefinition; +import com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec; +import com.organizacion.orchestrator.request.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; + +public final class RequestJson { + public static final int MAX_DATA_BYTES=262144,MAX_BODY_BYTES=MAX_DATA_BYTES+4096; + private final FlowJsonCodec codec; + private final ObjectMapper json=new ObjectMapper(JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(64).maxNumberLength(1000).build()).build()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + public RequestJson(FlowJsonCodec codec){this.codec=codec;} + public record Command(String typeKey,ObjectValue data){} + public Command command(String source){ + var n=parse(source); + if(n==null||!n.isObject()||n.size()!=2||!n.path("typeKey").isTextual()||!n.path("data").isObject())throw new IllegalArgumentException("Invalid command"); + Checks.key(n.get("typeKey").textValue()); + var data=(ObjectValue)codec.value(n.get("data"));data(data); + return new Command(n.get("typeKey").textValue(),data); + } + public JsonNode parse(String text){try{return json.readTree(text);}catch(Exception e){throw new IllegalArgumentException("Invalid JSON");}} + public String data(ObjectValue data){ + String value=codec.canonical(codec.tree(data)); + if(value.getBytes(StandardCharsets.UTF_8).length>MAX_DATA_BYTES)throw new DataTooLarge(); + return value; + } + public static final class DataTooLarge extends RuntimeException {} + public String command(String type,ObjectValue data){var n=json.createObjectNode().put("typeKey",type);n.set("data",codec.tree(data));return codec.canonical(n);} + public void validate(FlowDefinition flow,ObjectValue data){ + var target=codec.tree(data); + var schema=flow.dataSchema().orElseThrow(()->new DomainException(ErrorCode.INVALID_FLOW)); + for(var s:List.of(schema,flow.initialDataSchema().orElse(schema))){ + var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(codec.tree(s.value()), + SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + if(!validator.validate(target).isEmpty())throw new DomainException(ErrorCode.SCHEMA_VALIDATION_ERROR); + } + } + public String snapshot(RequestSnapshot snapshot){ + var r=snapshot.request();var n=json.createObjectNode().put("id",r.id().value().toString()).put("typeKey",r.typeKey()) + .put("flowId",r.flowId().value().toString()).put("flowKey",snapshot.flowKey()).put("flowVersion",snapshot.flowVersion()) + .put("currentState",r.currentState()).put("revision",r.revision()).put("createdAt",r.createdAt().toString()).put("updatedAt",r.updatedAt().toString()); + n.set("data",codec.tree(r.data()));return codec.canonical(n); + } + public RequestSnapshot snapshot(String source,Actor actor){ + var n=parse(source);return new RequestSnapshot(new RequestInstance(new RequestId(UUID.fromString(n.get("id").asText())),n.get("typeKey").asText(), + new FlowId(UUID.fromString(n.get("flowId").asText())),n.get("currentState").asText(),(ObjectValue)codec.value(n.get("data")), + actor.subject(),actor.businessScope(),n.get("revision").longValue(),Instant.parse(n.get("createdAt").asText()),Instant.parse(n.get("updatedAt").asText())), + n.get("flowKey").asText(),n.get("flowVersion").intValue(),Optional.empty()); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/api/ApiException.java b/src/main/java/com/organizacion/orchestrator/shared/api/ApiException.java new file mode 100644 index 0000000..e29f44a --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/api/ApiException.java @@ -0,0 +1,7 @@ +package com.organizacion.orchestrator.shared.api; + +public final class ApiException extends RuntimeException { + private final int status; + public ApiException(int status,String code){super(code);this.status=status;} + public int status(){return status;} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/api/ApiExceptionHandler.java b/src/main/java/com/organizacion/orchestrator/shared/api/ApiExceptionHandler.java new file mode 100644 index 0000000..c6b7c60 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/api/ApiExceptionHandler.java @@ -0,0 +1,40 @@ +package com.organizacion.orchestrator.shared.api; + +import com.organizacion.orchestrator.flow.api.ValidationReportResponse; +import com.organizacion.orchestrator.flow.application.FlowValidationException; +import com.organizacion.orchestrator.shared.domain.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.dao.DataAccessException; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +public class ApiExceptionHandler { + private ResponseEntity response(HttpServletRequest request,int status,String code){ + return ResponseEntity.status(status).contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(ProblemResponse.of(status,code,CorrelationFilter.id(request))); + } + @ExceptionHandler(ApiException.class) ResponseEntity api(ApiException e,HttpServletRequest r){return response(r,e.status(),e.getMessage());} + @ExceptionHandler(DomainException.class) ResponseEntity domain(DomainException e,HttpServletRequest r){ + return switch(e.code()){ + case ACCESS_DENIED->response(r,403,"ACCESS_DENIED"); + case RESOURCE_NOT_FOUND->response(r,404,"NOT_FOUND"); + case REVISION_CONFLICT->response(r,412,"REVISION_MISMATCH"); + case INVALID_FLOW->response(r,409,"INVALID_FLOW_STATE"); + case IDEMPOTENCY_CONFLICT->response(r,409,"IDEMPOTENCY_CONFLICT"); + case REQUEST_BUSY,INVALID_EVENT,RECONCILIATION_REQUIRED->response(r,409,e.code().name()); + default->response(r,422,e.code().name()); + }; + } + @ExceptionHandler(FlowValidationException.class) ResponseEntity validation(FlowValidationException e,HttpServletRequest r){ + var violations=ValidationReportResponse.from(e.report()).errors(); + return ResponseEntity.unprocessableContent().contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(new ProblemResponse("about:blank","INVALID_FLOW",422,"INVALID_FLOW",CorrelationFilter.id(r),violations)); + } + @ExceptionHandler({IllegalArgumentException.class,MethodArgumentTypeMismatchException.class}) + ResponseEntity badRequest(Exception e,HttpServletRequest r){return response(r,400,"INVALID_REQUEST");} + @ExceptionHandler(DataAccessException.class) ResponseEntity database(DataAccessException e,HttpServletRequest r){return response(r,503,"SERVICE_UNAVAILABLE");} + @ExceptionHandler(com.organizacion.orchestrator.request.infrastructure.RequestJson.DataTooLarge.class) + ResponseEntity oversized(Exception e,HttpServletRequest r){return response(r,413,"PAYLOAD_TOO_LARGE");} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/api/CommandObservationFilter.java b/src/main/java/com/organizacion/orchestrator/shared/api/CommandObservationFilter.java new file mode 100644 index 0000000..c02650f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/api/CommandObservationFilter.java @@ -0,0 +1,31 @@ +package com.organizacion.orchestrator.shared.api; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.slf4j.*; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + +/** Count transport outcomes, including authenticated replays; never inspect a body, query or JWT. */ +@Component @Order(Ordered.HIGHEST_PRECEDENCE+1) +public final class CommandObservationFilter extends OncePerRequestFilter { + private static final Logger log=LoggerFactory.getLogger(CommandObservationFilter.class); + private final MeterRegistry meters; + public CommandObservationFilter(MeterRegistry meters){this.meters=meters;} + protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{ + String path=request.getRequestURI().substring(request.getContextPath().length()); + String kind=path.equals("/api/v1/requests")?"create":path.matches("/api/v1/requests/[0-9a-fA-F-]{36}/events")?"event":null; + if(!request.getMethod().equals("POST")||kind==null){chain.doFilter(request,response);return;} + long start=System.nanoTime();boolean completed=false; + try{chain.doFilter(request,response);completed=true;} + finally{ + int status=completed?response.getStatus():500;String result=status>=200&&status<300?"accepted":status>=500?"error":"rejected"; + meters.counter("orchestrator.commands","command",kind,"result",result).increment(); + log.info("command={} result={} status={} correlationId={} durationMs={}",kind,result,status,CorrelationFilter.id(request),(System.nanoTime()-start)/1_000_000); + } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/api/CorrelationFilter.java b/src/main/java/com/organizacion/orchestrator/shared/api/CorrelationFilter.java new file mode 100644 index 0000000..c46307f --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/api/CorrelationFilter.java @@ -0,0 +1,22 @@ +package com.organizacion.orchestrator.shared.api; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.UUID; + +@Component @Order(Ordered.HIGHEST_PRECEDENCE) +public final class CorrelationFilter extends OncePerRequestFilter { + public static final String ATTRIBUTE="orchestrator.correlationId"; + public static String id(HttpServletRequest request){ + Object value=request.getAttribute(ATTRIBUTE);return value instanceof String s?s:UUID.randomUUID().toString(); + } + @Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{ + String id=UUID.randomUUID().toString();request.setAttribute(ATTRIBUTE,id);response.setHeader("X-Correlation-Id",id); + chain.doFilter(request,response); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/api/ProblemResponse.java b/src/main/java/com/organizacion/orchestrator/shared/api/ProblemResponse.java new file mode 100644 index 0000000..06afa2c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/api/ProblemResponse.java @@ -0,0 +1,11 @@ +package com.organizacion.orchestrator.shared.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.organizacion.orchestrator.flow.api.ValidationReportResponse; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ProblemResponse(String type,String title,int status,String code,String correlationId, + List errors) { + public static ProblemResponse of(int status,String code,String correlation){return new ProblemResponse("about:blank",code,status,code,correlation,null);} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/application/ResourceAccess.java b/src/main/java/com/organizacion/orchestrator/shared/application/ResourceAccess.java new file mode 100644 index 0000000..3809f67 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/application/ResourceAccess.java @@ -0,0 +1,23 @@ +package com.organizacion.orchestrator.shared.application; + +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore; +import com.organizacion.orchestrator.shared.domain.*; +import java.util.UUID; + +/** No implicit FLOW_ADMIN bypass of customer resource ownership. Missing and foreign resources look alike. */ +public final class ResourceAccess { + private final ResourceOwnershipStore store; + public ResourceAccess(ResourceOwnershipStore store){this.store=store;} + public ResourceOwner require(Actor actor,ResourceOwnershipStore.Kind kind,UUID id){ + var owner=store.owner(kind,id).orElseThrow(ResourceAccess::hidden); + if(!owner.businessScope().equals(actor.businessScope())||!owner.ownerSubject().equals(actor.subject()))throw hidden(); + return owner; + } + public ResourceOwner requireResolution(Actor actor,UUID executionId){ + if(!actor.roles().contains("EXECUTION_OPERATOR"))throw new DomainException(ErrorCode.ACCESS_DENIED); + var owner=store.owner(ResourceOwnershipStore.Kind.EXECUTION,executionId).orElseThrow(ResourceAccess::hidden); + if(!owner.businessScope().equals(actor.businessScope()))throw hidden(); + return owner; + } + private static DomainException hidden(){return new DomainException(ErrorCode.RESOURCE_NOT_FOUND);} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/application/port/ResourceOwnershipStore.java b/src/main/java/com/organizacion/orchestrator/shared/application/port/ResourceOwnershipStore.java new file mode 100644 index 0000000..eea3877 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/application/port/ResourceOwnershipStore.java @@ -0,0 +1,9 @@ +package com.organizacion.orchestrator.shared.application.port; + +import com.organizacion.orchestrator.shared.domain.ResourceOwner; +import java.util.*; + +public interface ResourceOwnershipStore { + enum Kind { REQUEST, EXECUTION, DOCUMENT, AUDIT_ENTRY } + Optional owner(Kind kind,UUID id); +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/Actor.java b/src/main/java/com/organizacion/orchestrator/shared/domain/Actor.java new file mode 100644 index 0000000..631d711 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/Actor.java @@ -0,0 +1,12 @@ +package com.organizacion.orchestrator.shared.domain; + +import java.util.Set; + +/** Construct only from verified identity. Never bind this record from event data. */ +public record Actor(String subject, String businessScope, Set roles) { + public Actor { + Checks.text(subject, 200); Checks.text(businessScope, 100); + roles = Set.copyOf(roles); + roles.forEach(Checks::key); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/Checks.java b/src/main/java/com/organizacion/orchestrator/shared/domain/Checks.java new file mode 100644 index 0000000..c813454 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/Checks.java @@ -0,0 +1,25 @@ +package com.organizacion.orchestrator.shared.domain; + +import java.util.Objects; + +public final class Checks { + private Checks() {} + public static String text(String value, int max) { + Objects.requireNonNull(value); + if (value.isBlank() || value.length() > max) throw new IllegalArgumentException("Invalid text length"); + return value; + } + public static String key(String value) { + if (!text(value, 100).matches("[A-Za-z][A-Za-z0-9_.-]{0,99}")) + throw new IllegalArgumentException("Invalid configuration key"); + return value; + } + public static long nonNegative(long value) { + if (value < 0) throw new IllegalArgumentException("Negative counter"); + return value; + } + public static int positive(int value) { + if (value < 1) throw new IllegalArgumentException("Non-positive version"); + return value; + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/DomainException.java b/src/main/java/com/organizacion/orchestrator/shared/domain/DomainException.java new file mode 100644 index 0000000..2ccba35 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/DomainException.java @@ -0,0 +1,13 @@ +package com.organizacion.orchestrator.shared.domain; + +import java.util.Objects; + +/** Carries a safe code, not payloads, credentials or raw provider responses. */ +public final class DomainException extends RuntimeException { + private final ErrorCode code; + public DomainException(ErrorCode code) { + super(Objects.requireNonNull(code).name()); + this.code = code; + } + public ErrorCode code() { return code; } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/ErrorCode.java b/src/main/java/com/organizacion/orchestrator/shared/domain/ErrorCode.java new file mode 100644 index 0000000..6f89af5 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/ErrorCode.java @@ -0,0 +1,10 @@ +package com.organizacion.orchestrator.shared.domain; + +/** Stable machine codes; transport status and localized messages belong to API adapters. */ +public enum ErrorCode { + EVALUATION_ERROR, AMBIGUOUS_DECISION, INVALID_FLOW, INVALID_EVENT, + REQUEST_BUSY, IDEMPOTENCY_CONFLICT, REVISION_CONFLICT, GUARD_NOT_SATISFIED, + SCHEMA_VALIDATION_ERROR, RESOURCE_NOT_FOUND, ACCESS_DENIED, + INTEGRATION_UNAVAILABLE, INTEGRATION_CONTRACT_ERROR, ACTION_FAILED, + LEASE_LOST, RECONCILIATION_REQUIRED +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/Identifiers.java b/src/main/java/com/organizacion/orchestrator/shared/domain/Identifiers.java new file mode 100644 index 0000000..767a50e --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/Identifiers.java @@ -0,0 +1,13 @@ +package com.organizacion.orchestrator.shared.domain; + +import java.util.Objects; +import java.util.UUID; + +/** Different resource identities cannot be passed interchangeably. */ +public final class Identifiers { + private Identifiers() {} + public record FlowId(UUID value) { public FlowId { Objects.requireNonNull(value); } } + public record RequestId(UUID value) { public RequestId { Objects.requireNonNull(value); } } + public record ExecutionId(UUID value) { public ExecutionId { Objects.requireNonNull(value); } } + public record ActionExecutionId(UUID value) { public ActionExecutionId { Objects.requireNonNull(value); } } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/JsonValue.java b/src/main/java/com/organizacion/orchestrator/shared/domain/JsonValue.java new file mode 100644 index 0000000..c3c2e6c --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/JsonValue.java @@ -0,0 +1,33 @@ +package com.organizacion.orchestrator.shared.domain; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Immutable JSON algebra, independent of serializers. Java null is never a JSON value. */ +public sealed interface JsonValue { + record ObjectValue(Map fields) implements JsonValue { + public ObjectValue { fields = Map.copyOf(fields); } + public static ObjectValue empty() { return new ObjectValue(Map.of()); } + } + record ArrayValue(List values) implements JsonValue { + public ArrayValue { values = List.copyOf(values); } + } + record StringValue(String value) implements JsonValue { + public StringValue { Objects.requireNonNull(value); } + } + record NumberValue(BigDecimal value) implements JsonValue { + public NumberValue { Objects.requireNonNull(value); } + } + record BooleanValue(boolean value) implements JsonValue {} + enum NullValue implements JsonValue { INSTANCE } + + /** Absence belongs to lookup results, never to stored JSON or literal values. */ + sealed interface Lookup { + record Present(JsonValue value) implements Lookup { + public Present { Objects.requireNonNull(value); } + } + enum Missing implements Lookup { INSTANCE } + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/domain/ResourceOwner.java b/src/main/java/com/organizacion/orchestrator/shared/domain/ResourceOwner.java new file mode 100644 index 0000000..ee61639 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/domain/ResourceOwner.java @@ -0,0 +1,8 @@ +package com.organizacion.orchestrator.shared.domain; + +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import java.util.Objects; + +public record ResourceOwner(RequestId requestId,String ownerSubject,String businessScope){ + public ResourceOwner{Objects.requireNonNull(requestId);Checks.text(ownerSubject,200);Checks.text(businessScope,100);} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/ObservabilityConfiguration.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/ObservabilityConfiguration.java new file mode 100644 index 0000000..b584434 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/ObservabilityConfiguration.java @@ -0,0 +1,15 @@ +package com.organizacion.orchestrator.shared.infrastructure.observability; + +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnExpression("${orchestrator.admin.enabled:false} or ${orchestrator.requests.enabled:false} or ${orchestrator.worker.enabled:false}") +public class ObservabilityConfiguration { + @Bean QueueMetrics queueMetrics(DataSource source,MeterRegistry registry,RuntimeRoleVerifier verified, + @Value("${orchestrator.database.schema:orchestrator}")String schema){return new QueueMetrics(source,schema,registry);} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/QueueMetrics.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/QueueMetrics.java new file mode 100644 index 0000000..df41e6b --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/observability/QueueMetrics.java @@ -0,0 +1,36 @@ +package com.organizacion.orchestrator.shared.infrastructure.observability; + +import io.micrometer.core.instrument.*; +import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; +import java.util.*; + +/** One bounded aggregate per scrape window, independent of request and tenant cardinality. */ +public final class QueueMetrics { + private final JdbcTemplate jdbc;private final String table; + private long sampledAt;private Map snapshot=Map.of(); + public QueueMetrics(DataSource source,String schema,MeterRegistry registry){ + if(!schema.matches("[a-z_][a-z0-9_]*"))throw new IllegalArgumentException("Invalid schema"); + jdbc=new JdbcTemplate(source);jdbc.setQueryTimeout(3);table="\""+schema+"\".execution"; + for(String state:List.of("PENDING","RUNNING","RETRY_WAIT","SUCCEEDED","FAILED","RECONCILIATION_REQUIRED")) + Gauge.builder("orchestrator.executions",this,m->m.value(state)).tag("state",state).register(registry); + Gauge.builder("orchestrator.pending.oldest.seconds",this,m->m.value("oldest")).register(registry); + Gauge.builder("orchestrator.leases.expired",this,m->m.value("expired")).register(registry); + Gauge.builder("orchestrator.queue.sample.available",this,m->m.value("available")).register(registry); + } + public synchronized double value(String key){ + long now=System.nanoTime(); + if(sampledAt==0||now-sampledAt>java.time.Duration.ofSeconds(5).toNanos()){ + var next=new HashMap(); + try{ + jdbc.query("SELECT status,count(*) AS n,coalesce(max(extract(epoch FROM (clock_timestamp()-created_at))) FILTER (WHERE status='PENDING'),0) AS oldest,count(*) FILTER (WHERE status='RUNNING' AND lease_until{ + next.put(rs.getString("status"),rs.getDouble("n")); + if(rs.getString("status").equals("PENDING"))next.put("oldest",Math.max(0,rs.getDouble("oldest"))); + if(rs.getString("status").equals("RUNNING"))next.put("expired",rs.getDouble("expired")); + });next.put("available",1d); + }catch(org.springframework.dao.DataAccessException e){next.put("available",0d);} + snapshot=Map.copyOf(next);sampledAt=now; + } + return snapshot.getOrDefault(key,snapshot.getOrDefault("available",0d)==0?Double.NaN:0d); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java new file mode 100644 index 0000000..171bbb3 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/JwtActorMapper.java @@ -0,0 +1,41 @@ +package com.organizacion.orchestrator.shared.infrastructure.security; + +import com.organizacion.orchestrator.shared.domain.Actor; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.Jwt; +import java.util.*; + +/** Converts only verified JWT claims. OAuth scopes and body fields cannot grant technical roles. */ +public final class JwtActorMapper { + private final String businessScopeClaim,clientId; + public JwtActorMapper(String businessScopeClaim,String clientId){ + if(businessScopeClaim.isBlank()||clientId.isBlank())throw new IllegalArgumentException("Missing identity mapping"); + this.businessScopeClaim=businessScopeClaim;this.clientId=clientId; + } + public Actor actor(Jwt jwt){ + try{ + Object scope=jwt.getClaims().get(businessScopeClaim); + if(!(scope instanceof String value))throw invalid(); + var roles=new HashSet(); + addRoles(jwt.getClaims().get("realm_access"),roles); + Object resources=jwt.getClaims().get("resource_access"); + if(resources!=null){ + if(!(resources instanceof Map clients))throw invalid(); + addRoles(clients.get(clientId),roles); + } + return new Actor(jwt.getSubject(),value,roles); + }catch(IllegalArgumentException|NullPointerException e){throw invalid();} + } + private void addRoles(Object section,Set roles){ + if(section==null)return; + if(!(section instanceof Map map))throw invalid(); + Object values=map.get("roles");if(values==null)return; + if(!(values instanceof Collection collection)||collection.size()>200)throw invalid(); + for(Object role:collection){ + if(!(role instanceof String text))throw invalid(); + // Ignore unrelated provider role names outside the DSL vocabulary; never broaden them. + if(text.matches("[A-Za-z][A-Za-z0-9_.-]{0,99}"))roles.add(text); + } + } + private static OAuth2AuthenticationException invalid(){return new OAuth2AuthenticationException(new OAuth2Error("invalid_token","Invalid identity claims",null));} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PersistenceAccessConfiguration.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PersistenceAccessConfiguration.java new file mode 100644 index 0000000..d2a4d15 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PersistenceAccessConfiguration.java @@ -0,0 +1,20 @@ +package com.organizacion.orchestrator.shared.infrastructure.security; + +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.infrastructure.*; +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.sql.init.dependency.DependsOnDatabaseInitialization; +import org.springframework.context.annotation.*; +import javax.sql.DataSource; + +@Configuration(proxyBeanMethods=false) +@ConditionalOnExpression("${orchestrator.admin.enabled:false} or ${orchestrator.requests.enabled:false} or ${orchestrator.worker.enabled:false}") +public class PersistenceAccessConfiguration { + @Bean FlowJsonCodec flowJsonCodec(){return new FlowJsonCodec();} + @Bean @DependsOnDatabaseInitialization + RuntimeRoleVerifier runtimeRoleVerifier(DataSource source,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new RuntimeRoleVerifier(source,schema);} + @Bean FlowStore flowStore(DataSource source,FlowJsonCodec codec,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new PostgresFlowStore(source,codec,schema);} + @Bean ResourceAccess resourceAccess(DataSource source,RuntimeRoleVerifier verified,@Value("${orchestrator.database.schema:orchestrator}")String schema){return new ResourceAccess(new PostgresResourceOwnershipStore(source,schema));} +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PostgresResourceOwnershipStore.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PostgresResourceOwnershipStore.java new file mode 100644 index 0000000..f23cfa0 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/PostgresResourceOwnershipStore.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator.shared.infrastructure.security; + +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; +import java.util.*; + +public final class PostgresResourceOwnershipStore implements ResourceOwnershipStore { + private final JdbcTemplate jdbc;private final String schema; + public PostgresResourceOwnershipStore(DataSource dataSource,String schema){ + if(!schema.matches("orchestrator|t0[78]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + this.schema="\""+schema+"\".";jdbc=new JdbcTemplate(dataSource); + } + public Optional owner(Kind kind,UUID id){ + String query="SELECT r.id,r.owner_subject,r.business_scope FROM "+schema+"request_instance r "; + query+=switch(kind){ + case REQUEST->"WHERE r.id=?"; + case EXECUTION->"JOIN "+schema+"execution c ON c.request_id=r.id WHERE c.id=?"; + case DOCUMENT->"JOIN "+schema+"document_reference c ON c.request_id=r.id WHERE c.id=?"; + case AUDIT_ENTRY->"JOIN "+schema+"audit_entry c ON c.request_id=r.id WHERE c.id=?"; + }; + return jdbc.query(query,(r,i)->new ResourceOwner(new RequestId(r.getObject(1,UUID.class)),r.getString(2),r.getString(3)),id).stream().findFirst(); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/RuntimeRoleVerifier.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/RuntimeRoleVerifier.java new file mode 100644 index 0000000..edced79 --- /dev/null +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/RuntimeRoleVerifier.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator.shared.infrastructure.security; + +import org.springframework.jdbc.core.JdbcTemplate; +import javax.sql.DataSource; + +/** Fail closed before exposing admin writes with a privileged or migration identity. Never grants privileges. */ +public final class RuntimeRoleVerifier { + public RuntimeRoleVerifier(DataSource source,String schema){ + if(!schema.matches("orchestrator|t0[78]_[a-f0-9]{32}"))throw new IllegalArgumentException("Unexpected schema"); + var jdbc=new JdbcTemplate(source); + Boolean elevated=jdbc.queryForObject("SELECT EXISTS(SELECT 1 FROM pg_roles r WHERE pg_has_role(current_user,r.rolname,'MEMBER') " + +"AND (r.rolsuper OR r.rolcreatedb OR r.rolcreaterole OR r.rolreplication OR r.rolbypassrls)) " + +"OR EXISTS(SELECT 1 FROM pg_namespace n WHERE n.nspname=? AND pg_has_role(current_user,pg_get_userbyid(n.nspowner),'MEMBER')) " + +"OR has_schema_privilege(current_user,?,'CREATE')",Boolean.class,schema,schema); + if(!Boolean.FALSE.equals(elevated))throw new IllegalStateException("Administration requires a separate least-privilege runtime database role"); + for(String table:new String[]{"audit_entry","admin_audit","request_creation_idempotency","reconciliation_resolution"}){ + if(!Boolean.TRUE.equals(jdbc.queryForObject("SELECT has_table_privilege(current_user,?,'SELECT') AND has_table_privilege(current_user,?,'INSERT') " + +"AND NOT has_table_privilege(current_user,?,'UPDATE,DELETE,TRUNCATE,TRIGGER')",Boolean.class, + schema+"."+table,schema+"."+table,schema+"."+table))) + throw new IllegalStateException("Invalid runtime history permissions"); + } + if(!Boolean.FALSE.equals(jdbc.queryForObject("SELECT has_table_privilege(current_user,?,'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,TRIGGER')", + Boolean.class,schema+".flyway_schema_history"))) + throw new IllegalStateException("Runtime must not access migration history"); + } +} diff --git a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java index a3597a9..2bb07eb 100644 --- a/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java +++ b/src/main/java/com/organizacion/orchestrator/shared/infrastructure/security/SecurityConfiguration.java @@ -2,7 +2,18 @@ package com.organizacion.orchestrator.shared.infrastructure.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.*; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import com.organizacion.orchestrator.shared.api.CorrelationFilter; +import jakarta.servlet.http.*; +import java.io.IOException; +import java.util.*; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @@ -10,14 +21,80 @@ import org.springframework.security.web.SecurityFilterChain; @Configuration(proxyBeanMethods = false) public class SecurityConfiguration { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + JwtActorMapper jwtActorMapper(@Value("${orchestrator.security.business-scope-claim:business_scope}") String scope, + @Value("${spring.security.oauth2.resourceserver.jwt.audiences}") String audience){ + return new JwtActorMapper(scope,audience); + } + @Bean + JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwks, + @Value("${spring.security.oauth2.resourceserver.jwt.audiences}") String audience){ + var decoder=NimbusJwtDecoder.withJwkSetUri(jwks).jwsAlgorithm(SignatureAlgorithm.RS256).build(); + OAuth2TokenValidator required=jwt->{ + if(jwt.getExpiresAt()==null||jwt.getSubject()==null||jwt.getSubject().isBlank() + ||jwt.getAudience()==null||!jwt.getAudience().contains(audience)) + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token","Required claims are missing or invalid",null)); + return OAuth2TokenValidatorResult.success(); + }; + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefaultWithIssuer(issuer),required)); + return decoder; + } + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http,JwtActorMapper actors, + @Value("${orchestrator.admin.enabled:false}") boolean enabled, + @Value("${orchestrator.admin.business-scopes:}") String scopes) throws Exception { + Set adminScopes=new HashSet<>(); + Arrays.stream(scopes.split(",")).map(String::trim).filter(s->!s.isEmpty()).forEach(adminScopes::add); return http .csrf(csrf -> csrf.disable()) + .requestCache(cache -> cache.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/actuator/health", "/actuator/health/liveness", "/actuator/health/readiness").permitAll() + .requestMatchers("/actuator/metrics", "/actuator/metrics/**").access((authentication,context)->{ + var auth=authentication.get(); + if(!(auth instanceof JwtAuthenticationToken token))return new AuthorizationDecision(false); + var actor=actors.actor(token.getToken()); + return new AuthorizationDecision(actor.roles().contains("EXECUTION_OPERATOR")&&adminScopes.contains(actor.businessScope())); + }) + .requestMatchers("/actuator/**").denyAll() + .requestMatchers("/api/v1/admin/**").access((authentication,context)->{ + var auth=authentication.get(); + if(!enabled||!(auth instanceof JwtAuthenticationToken token))return new AuthorizationDecision(false); + var actor=actors.actor(token.getToken()); + if(!adminScopes.contains(actor.businessScope()))return new AuthorizationDecision(false); + var request=context.getRequest(); + String path=request.getRequestURI().substring(request.getContextPath().length()); + Set requiredRoles=adminRoles(request.getMethod(),path); + return new AuthorizationDecision(actor.roles().stream().anyMatch(requiredRoles::contains)); + }) .anyRequest().authenticated()) - .oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults())) + .exceptionHandling(errors->errors.authenticationEntryPoint((request,response,e)->problem(request,response,401,"UNAUTHENTICATED")) + .accessDeniedHandler((request,response,e)->problem(request,response,403,"ACCESS_DENIED"))) + .oauth2ResourceServer(resourceServer -> resourceServer + .authenticationEntryPoint((request,response,e)->problem(request,response,401,"INVALID_TOKEN")) + .accessDeniedHandler((request,response,e)->problem(request,response,403,"ACCESS_DENIED")) + .jwt(jwt->jwt.jwtAuthenticationConverter(token->{ + var actor=actors.actor(token); + return new JwtAuthenticationToken(token,actor.roles().stream().map(r->new SimpleGrantedAuthority("ROLE_"+r)).toList(),actor.subject()); + }))) .build(); } + private static Set adminRoles(String method,String path){ + if(method.equals("POST")&&path.equals("/api/v1/admin/flows"))return Set.of("FLOW_EDITOR"); + if(method.equals("PUT")&&path.matches("/api/v1/admin/flows/[^/]+"))return Set.of("FLOW_EDITOR"); + if(method.equals("POST")&&path.matches("/api/v1/admin/flows/[^/]+/validate"))return Set.of("FLOW_EDITOR","FLOW_PUBLISHER"); + if(method.equals("POST")&&path.matches("/api/v1/admin/flows/[^/]+/simulate"))return Set.of("FLOW_EDITOR"); + if(method.equals("POST")&&path.matches("/api/v1/admin/flows/[^/]+/publish"))return Set.of("FLOW_PUBLISHER"); + if(method.equals("POST")&&path.matches("/api/v1/admin/flows/[^/]+/retire"))return Set.of("FLOW_ADMIN"); + if(method.equals("PUT")&&path.matches("/api/v1/admin/request-types/[^/]+/active-flow"))return Set.of("FLOW_ADMIN"); + if(method.equals("POST")&&path.matches("/api/v1/admin/executions/[^/]+/resolve"))return Set.of("EXECUTION_OPERATOR"); + return Set.of(); + } + private static void problem(HttpServletRequest request,HttpServletResponse response,int status,String code)throws IOException{ + response.setStatus(status);response.setContentType("application/problem+json");response.setCharacterEncoding("UTF-8"); + if(status==401)response.setHeader("WWW-Authenticate","Bearer"); + response.getWriter().write("{\"type\":\"about:blank\",\"title\":\""+code+"\",\"status\":"+status + +",\"code\":\""+code+"\",\"correlationId\":\""+CorrelationFilter.id(request)+"\"}"); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3ecc92b..5a6d062 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -11,12 +11,19 @@ spring: ddl-auto: validate properties: hibernate: + default_schema: orchestrator jdbc: time_zone: UTC flyway: enabled: true locations: classpath:db/migration clean-disabled: true + default-schema: orchestrator + schemas: orchestrator + placeholders: + orchestratorSchema: orchestrator + user: ${DB_MIGRATION_USERNAME:${DB_USERNAME}} + password: ${DB_MIGRATION_PASSWORD:${DB_PASSWORD}} security: oauth2: resourceserver: @@ -35,7 +42,7 @@ management: endpoints: web: exposure: - include: health + include: health,metrics endpoint: health: show-details: never @@ -46,3 +53,24 @@ management: include: readinessState,db liveness: include: livenessState + +orchestrator: + integrations: + routes-path: ${INTEGRATION_ROUTES_PATH:config/integration-routes.json} + worker: + enabled: ${WORKER_ENABLED:false} + concurrency: ${WORKER_CONCURRENCY:10} + batch: ${WORKER_BATCH:10} + poll-ms: ${WORKER_POLL_MS:500} + lease-seconds: ${WORKER_LEASE_SECONDS:60} + renew-seconds: ${WORKER_RENEW_SECONDS:15} + shutdown-seconds: ${WORKER_SHUTDOWN_SECONDS:20} + requests: + enabled: ${REQUEST_API_ENABLED:false} + security: + business-scope-claim: ${JWT_BUSINESS_SCOPE_CLAIM:business_scope} + admin: + enabled: ${FLOW_ADMIN_ENABLED:false} + business-scopes: ${ADMIN_BUSINESS_SCOPES:} + flow: + catalog-path: ${FLOW_CATALOG_PATH:config/publication-catalog.json} diff --git a/src/main/resources/db/migration/README.md b/src/main/resources/db/migration/README.md index 9f5373f..8a05c34 100644 --- a/src/main/resources/db/migration/README.md +++ b/src/main/resources/db/migration/README.md @@ -1,6 +1,57 @@ -# Migraciones +# Migraciones PostgreSQL -Las migraciones del modelo se implementan en T03, después del contrato T02. -Usar V1__descripcion.sql y versiones consecutivas; nunca editar una migración aplicada. -Separar el rol de migraciones del rol de ejecución al implementar privilegios. -No se incluye un esquema parcial que pueda confundirse con el modelo aprobado. +V1 crea diez tablas: flow_definition, request_type, request_instance, execution, +action_execution, document_reference, audit_entry, admin_audit, +request_creation_idempotency y reconciliation_resolution. Incluye FKs, checks +JSONB e índices de cola, leases, consultas e idempotencia. El índice único de + ejecución activa incluye RECONCILIATION_REQUIRED. + +V2 protege versiones publicadas/retiradas e históricos contra UPDATE, DELETE y +TRUNCATE. Editar borradores requiere incrementar revision; publicar requiere +checksum y fecha. La validación semántica y auditoría atómica de publicación +corresponden a T06, no a estos triggers. + +El esquema del servicio y su historial Flyway es `orchestrator`. +`${orchestratorSchema}` permite ejecutar las mismas migraciones en esquemas de +prueba. No editar migraciones aplicadas; añadir versiones consecutivas. +Flyway clean está deshabilitado. La base debe existir previamente. + +## Roles + +El migrador crea el esquema y aplica migraciones. Configurar sus credenciales en +DB_MIGRATION_USERNAME y DB_MIGRATION_PASSWORD. Si se omiten, se usan +DB_USERNAME/DB_PASSWORD por compatibilidad con desarrollo. Ese fallback no +representa separación de privilegios. + +El administrador provisiona por separado un usuario de aplicación sin privilegios +administrativos ni pertenencia al propietario. Tras migrar, el propietario concede +permisos al usuario existente: + +```sql +SELECT orchestrator.grant_runtime_access('orchestrator_app'::name); +``` + +Usar ese usuario en DB_USERNAME/DB_PASSWORD, conservando el migrador separado. +La función no crea usuarios/contraseñas ni usa SECURITY DEFINER. Rechaza roles +administrativos o miembros del propietario. El migrador aprovisiona request_type; +el runtime puede consultar y cambiar su activación. Auditoría, documentos y +resoluciones admiten SELECT/INSERT; el runtime no recibe DDL, TRUNCATE ni acceso +al historial Flyway. Revisar también permisos heredados: la función concede +permisos, no elimina concesiones ajenas. Un superusuario/propietario puede + desactivar deliberadamente los triggers; el runtime debe usar otro rol. + +## Pruebas + +DatabaseMigrationIT aplica V1/V2 desde cero en `t03_` y elimina únicamente +ese esquema al finalizar. Verifica constraints, permisos, rollback, concurrencia +y SKIP LOCKED. El rol NOLOGIN de prueba se crea dentro de una transacción y se +revierte con ROLLBACK. El usuario de pruebas necesita crear esquemas y roles. + +`./mvnw clean verify` usa Testcontainers `postgres:9.6.24` y requiere Docker. +La alternativa externa exige una URL terminada exactamente en `/orquestador_test`; +ver [RUNBOOK](../../../../../specs/RUNBOOK.md). + +T10 agrega V3__action_effect_checkpoint.sql sin modificar V1/V2. La columna effect +conserva clasificación confiable para recuperación; checkpoints legados se consideran +NON_IDEMPOTENT_WRITE. Grants V2 sobre la tabla cubren la nueva columna; no hay roles +ni secretos nuevos. Ver WORKER.md y ADR-014. diff --git a/src/main/resources/db/migration/V1__orchestrator_tables.sql b/src/main/resources/db/migration/V1__orchestrator_tables.sql new file mode 100644 index 0000000..8181373 --- /dev/null +++ b/src/main/resources/db/migration/V1__orchestrator_tables.sql @@ -0,0 +1,179 @@ +-- PostgreSQL 9.6+. Flyway creates the configured schema before this migration. +-- orchestratorSchema is fixed to orchestrator in runtime; tests use a generated schema. +CREATE TABLE "${orchestratorSchema}".flow_definition ( + id uuid PRIMARY KEY, + flow_key varchar(100) NOT NULL CHECK (btrim(flow_key) <> ''), + version integer NOT NULL CHECK (version > 0), + status varchar(20) NOT NULL CHECK (status IN ('DRAFT','PUBLISHED','RETIRED')), + schema_version integer NOT NULL CHECK (schema_version > 0), + definition jsonb NOT NULL CHECK (jsonb_typeof(definition) = 'object'), + checksum varchar(64) CHECK (checksum ~ '^[0-9a-f]{64}$'), + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + created_by varchar(200) NOT NULL CHECK (btrim(created_by) <> ''), + published_at timestamptz, + CONSTRAINT uq_flow_key_version UNIQUE (flow_key, version), + CONSTRAINT ck_flow_publication CHECK ( + (status = 'DRAFT' AND checksum IS NULL AND published_at IS NULL) + OR (status IN ('PUBLISHED','RETIRED') AND checksum IS NOT NULL AND published_at IS NOT NULL) + ) +); + +CREATE TABLE "${orchestratorSchema}".request_type ( + type_key varchar(100) PRIMARY KEY CHECK (btrim(type_key) <> ''), + active_flow_id uuid REFERENCES "${orchestratorSchema}".flow_definition(id), + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0) +); + +CREATE TABLE "${orchestratorSchema}".request_instance ( + id uuid PRIMARY KEY, + type_key varchar(100) NOT NULL REFERENCES "${orchestratorSchema}".request_type(type_key), + flow_id uuid NOT NULL REFERENCES "${orchestratorSchema}".flow_definition(id), + current_state varchar(100) NOT NULL CHECK (btrim(current_state) <> ''), + data jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(data) = 'object'), + owner_subject varchar(200) NOT NULL CHECK (btrim(owner_subject) <> ''), + business_scope varchar(100) NOT NULL CHECK (btrim(business_scope) <> ''), + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_request_flow UNIQUE (id, flow_id) +); + +CREATE TABLE "${orchestratorSchema}".execution ( + id uuid PRIMARY KEY, + request_id uuid NOT NULL REFERENCES "${orchestratorSchema}".request_instance(id), + idempotency_key varchar(200) NOT NULL CHECK (btrim(idempotency_key) <> ''), + payload_hash varchar(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + actor_subject varchar(200) NOT NULL CHECK (btrim(actor_subject) <> ''), + actor_snapshot jsonb NOT NULL CHECK (jsonb_typeof(actor_snapshot) = 'object'), + event_key varchar(100) NOT NULL CHECK (btrim(event_key) <> ''), + event_data jsonb NOT NULL CHECK (jsonb_typeof(event_data) = 'object'), + transition_id varchar(100) NOT NULL CHECK (btrim(transition_id) <> ''), + from_state varchar(100) NOT NULL CHECK (btrim(from_state) <> ''), + accepted_revision bigint NOT NULL CHECK (accepted_revision >= 0), + status varchar(40) NOT NULL CHECK (status IN + ('PENDING','RUNNING','RETRY_WAIT','SUCCEEDED','FAILED','RECONCILIATION_REQUIRED')), + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + next_attempt_at timestamptz NOT NULL DEFAULT now(), + lease_owner varchar(200) CHECK (lease_owner IS NULL OR btrim(lease_owner) <> ''), + lease_until timestamptz, + fencing_token bigint NOT NULL DEFAULT 0 CHECK (fencing_token >= 0), + result jsonb CHECK (result IS NULL OR jsonb_typeof(result) = 'object'), + error_code varchar(100), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_execution_idempotency UNIQUE (request_id, idempotency_key), + CONSTRAINT uq_execution_request UNIQUE (id, request_id), + CONSTRAINT ck_execution_lease CHECK ( + (status = 'RUNNING' AND lease_owner IS NOT NULL AND lease_until IS NOT NULL) + OR (status <> 'RUNNING' AND lease_owner IS NULL AND lease_until IS NULL) + ), + CONSTRAINT ck_execution_success CHECK (status <> 'SUCCEEDED' OR result IS NOT NULL) +); + +CREATE UNIQUE INDEX uq_request_active_execution ON "${orchestratorSchema}".execution(request_id) + WHERE status IN ('PENDING','RUNNING','RETRY_WAIT','RECONCILIATION_REQUIRED'); +CREATE INDEX ix_execution_due ON "${orchestratorSchema}".execution(next_attempt_at, created_at) + WHERE status IN ('PENDING','RETRY_WAIT'); +CREATE INDEX ix_execution_lease ON "${orchestratorSchema}".execution(lease_until) WHERE status = 'RUNNING'; +CREATE INDEX ix_request_scope_state ON "${orchestratorSchema}".request_instance(business_scope, current_state, created_at, id); +CREATE INDEX ix_request_type_flow ON "${orchestratorSchema}".request_type(active_flow_id); +CREATE INDEX ix_request_flow ON "${orchestratorSchema}".request_instance(flow_id); +CREATE INDEX ix_request_type ON "${orchestratorSchema}".request_instance(type_key); + +CREATE TABLE "${orchestratorSchema}".action_execution ( + id uuid PRIMARY KEY, + execution_id uuid NOT NULL REFERENCES "${orchestratorSchema}".execution(id), + action_id varchar(100) NOT NULL CHECK (btrim(action_id) <> ''), + status varchar(30) NOT NULL CHECK (status IN ('PENDING','RUNNING','SUCCEEDED','FAILED','UNKNOWN')), + attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + external_idempotency_key varchar(200) NOT NULL CHECK (btrim(external_idempotency_key) <> ''), + input_hash varchar(64) NOT NULL CHECK (input_hash ~ '^[0-9a-f]{64}$'), + result jsonb CHECK (result IS NULL OR jsonb_typeof(result) = 'object'), + provider_reference varchar(200), + error_code varchar(100), + started_at timestamptz, + finished_at timestamptz, + CONSTRAINT uq_execution_action UNIQUE (execution_id, action_id), + CONSTRAINT uq_action_external_key UNIQUE (external_idempotency_key), + CONSTRAINT ck_action_success CHECK (status <> 'SUCCEEDED' OR (result IS NOT NULL AND finished_at IS NOT NULL)), + CONSTRAINT ck_action_time CHECK (finished_at IS NULL OR (started_at IS NOT NULL AND finished_at >= started_at)) +); + +CREATE TABLE "${orchestratorSchema}".document_reference ( + id uuid PRIMARY KEY, + request_id uuid NOT NULL REFERENCES "${orchestratorSchema}".request_instance(id), + document_type varchar(100) NOT NULL CHECK (btrim(document_type) <> ''), + storage_document_id varchar(200) NOT NULL CHECK (btrim(storage_document_id) <> ''), + mime_type varchar(100) NOT NULL CHECK (btrim(mime_type) <> ''), + size_bytes bigint NOT NULL CHECK (size_bytes >= 0), + checksum varchar(128) NOT NULL CHECK (btrim(checksum) <> ''), + created_by varchar(200) NOT NULL CHECK (btrim(created_by) <> ''), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_document_reference UNIQUE (request_id, document_type, storage_document_id) +); + +CREATE TABLE "${orchestratorSchema}".audit_entry ( + id uuid PRIMARY KEY, + request_id uuid NOT NULL, + execution_id uuid, + kind varchar(100) NOT NULL CHECK (btrim(kind) <> ''), + actor_subject varchar(200) NOT NULL CHECK (btrim(actor_subject) <> ''), + flow_id uuid NOT NULL, + from_state varchar(100), + to_state varchar(100), + detail jsonb NOT NULL CHECK (jsonb_typeof(detail) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT fk_audit_request_flow FOREIGN KEY (request_id, flow_id) + REFERENCES "${orchestratorSchema}".request_instance(id, flow_id), + CONSTRAINT fk_audit_execution_request FOREIGN KEY (execution_id, request_id) + REFERENCES "${orchestratorSchema}".execution(id, request_id) +); +CREATE INDEX ix_audit_request ON "${orchestratorSchema}".audit_entry(request_id, created_at, id); +CREATE INDEX ix_audit_execution ON "${orchestratorSchema}".audit_entry(execution_id, request_id); + +CREATE TABLE "${orchestratorSchema}".admin_audit ( + id uuid PRIMARY KEY, + flow_id uuid NOT NULL REFERENCES "${orchestratorSchema}".flow_definition(id), + action varchar(100) NOT NULL CHECK (btrim(action) <> ''), + actor_subject varchar(200) NOT NULL CHECK (btrim(actor_subject) <> ''), + previous_checksum varchar(64) CHECK (previous_checksum ~ '^[0-9a-f]{64}$'), + new_checksum varchar(64) CHECK (new_checksum ~ '^[0-9a-f]{64}$'), + correlation_id varchar(200) NOT NULL CHECK (btrim(correlation_id) <> ''), + detail jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(detail) = 'object'), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX ix_admin_audit_flow ON "${orchestratorSchema}".admin_audit(flow_id, created_at, id); + +CREATE TABLE "${orchestratorSchema}".request_creation_idempotency ( + id uuid PRIMARY KEY, + actor_subject varchar(200) NOT NULL CHECK (btrim(actor_subject) <> ''), + business_scope varchar(100) NOT NULL CHECK (btrim(business_scope) <> ''), + idempotency_key varchar(200) NOT NULL CHECK (btrim(idempotency_key) <> ''), + payload_hash varchar(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + request_id uuid NOT NULL REFERENCES "${orchestratorSchema}".request_instance(id), + response jsonb NOT NULL CHECK (jsonb_typeof(response) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_creation_idempotency UNIQUE (actor_subject, business_scope, idempotency_key) +); +CREATE INDEX ix_creation_request ON "${orchestratorSchema}".request_creation_idempotency(request_id); + +CREATE TABLE "${orchestratorSchema}".reconciliation_resolution ( + id uuid PRIMARY KEY, + execution_id uuid NOT NULL REFERENCES "${orchestratorSchema}".execution(id), + idempotency_key varchar(200) NOT NULL CHECK (btrim(idempotency_key) <> ''), + payload_hash varchar(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + mode varchar(40) NOT NULL CHECK (mode IN ('RESUME','CLOSE_WITHOUT_TRANSITION')), + actor_subject varchar(200) NOT NULL CHECK (btrim(actor_subject) <> ''), + evidence jsonb NOT NULL CHECK (jsonb_typeof(evidence) = 'object' AND evidence <> '{}'::jsonb), + verified_actions jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(verified_actions) = 'array'), + effect_status varchar(40) CHECK (effect_status IN ('ABSENT_VERIFIED','COMPENSATED_VERIFIED')), + result jsonb NOT NULL CHECK (jsonb_typeof(result) = 'object'), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_resolution_idempotency UNIQUE (execution_id, idempotency_key), + CONSTRAINT ck_resolution_mode CHECK ( + (mode = 'RESUME' AND jsonb_array_length(verified_actions) > 0 AND effect_status IS NULL) + OR (mode = 'CLOSE_WITHOUT_TRANSITION' AND effect_status IS NOT NULL AND verified_actions = '[]'::jsonb) + ) +); diff --git a/src/main/resources/db/migration/V2__immutability_and_runtime_privileges.sql b/src/main/resources/db/migration/V2__immutability_and_runtime_privileges.sql new file mode 100644 index 0000000..571840a --- /dev/null +++ b/src/main/resources/db/migration/V2__immutability_and_runtime_privileges.sql @@ -0,0 +1,102 @@ +CREATE FUNCTION "${orchestratorSchema}".protect_flow_definition() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status <> 'DRAFT' THEN + RAISE EXCEPTION 'Flow must be created as DRAFT' USING ERRCODE = '55000'; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.status <> 'DRAFT' THEN + RAISE EXCEPTION 'Published or retired flow cannot be deleted' USING ERRCODE = '55000'; + END IF; + RETURN OLD; + END IF; + + IF NEW.id IS DISTINCT FROM OLD.id OR NEW.flow_key IS DISTINCT FROM OLD.flow_key + OR NEW.version IS DISTINCT FROM OLD.version OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.created_by IS DISTINCT FROM OLD.created_by THEN + RAISE EXCEPTION 'Flow identity is immutable' USING ERRCODE = '55000'; + END IF; + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF OLD.status = 'DRAFT' THEN + IF NEW.status NOT IN ('DRAFT','PUBLISHED') OR NEW.revision <> OLD.revision + 1 THEN + RAISE EXCEPTION 'Invalid draft transition or revision' USING ERRCODE = '55000'; + END IF; + RETURN NEW; + END IF; + IF OLD.status = 'PUBLISHED' AND NEW.status = 'RETIRED' AND NEW.revision = OLD.revision + 1 + AND (to_jsonb(NEW) - 'status' - 'revision') = (to_jsonb(OLD) - 'status' - 'revision') THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'Published or retired flow is immutable' USING ERRCODE = '55000'; +END; +$$; + +CREATE FUNCTION "${orchestratorSchema}".reject_history_mutation() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +BEGIN + RAISE EXCEPTION 'Append-only relation: %', TG_TABLE_NAME USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER tr_flow_immutable BEFORE INSERT OR UPDATE OR DELETE + ON "${orchestratorSchema}".flow_definition FOR EACH ROW + EXECUTE PROCEDURE "${orchestratorSchema}".protect_flow_definition(); +CREATE TRIGGER tr_flow_no_truncate BEFORE TRUNCATE + ON "${orchestratorSchema}".flow_definition FOR EACH STATEMENT + EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); + +CREATE TRIGGER tr_audit_append_only BEFORE UPDATE OR DELETE ON "${orchestratorSchema}".audit_entry + FOR EACH ROW EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_audit_no_truncate BEFORE TRUNCATE ON "${orchestratorSchema}".audit_entry + FOR EACH STATEMENT EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_admin_audit_append_only BEFORE UPDATE OR DELETE ON "${orchestratorSchema}".admin_audit + FOR EACH ROW EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_admin_audit_no_truncate BEFORE TRUNCATE ON "${orchestratorSchema}".admin_audit + FOR EACH STATEMENT EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_creation_append_only BEFORE UPDATE OR DELETE ON "${orchestratorSchema}".request_creation_idempotency + FOR EACH ROW EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_creation_no_truncate BEFORE TRUNCATE ON "${orchestratorSchema}".request_creation_idempotency + FOR EACH STATEMENT EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_resolution_append_only BEFORE UPDATE OR DELETE ON "${orchestratorSchema}".reconciliation_resolution + FOR EACH ROW EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); +CREATE TRIGGER tr_resolution_no_truncate BEFORE TRUNCATE ON "${orchestratorSchema}".reconciliation_resolution + FOR EACH STATEMENT EXECUTE PROCEDURE "${orchestratorSchema}".reject_history_mutation(); + +REVOKE ALL ON SCHEMA "${orchestratorSchema}" FROM PUBLIC; +REVOKE ALL ON ALL TABLES IN SCHEMA "${orchestratorSchema}" FROM PUBLIC; +REVOKE ALL ON ALL FUNCTIONS IN SCHEMA "${orchestratorSchema}" FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA "${orchestratorSchema}" REVOKE ALL ON TABLES FROM PUBLIC; +ALTER DEFAULT PRIVILEGES IN SCHEMA "${orchestratorSchema}" REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; + +-- Administrator/migration owner calls this explicitly for an EXISTING least-privilege role. +-- No cluster roles or passwords are created by migrations. No SECURITY DEFINER escalation. +CREATE FUNCTION "${orchestratorSchema}".grant_runtime_access(runtime_role name) RETURNS void +LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog AS $$ +DECLARE + target_role record; + owner_role name; +BEGIN + SELECT * INTO target_role FROM pg_roles WHERE rolname = runtime_role; + IF NOT FOUND THEN + RAISE EXCEPTION 'Runtime role does not exist' USING ERRCODE = '22023'; + END IF; + SELECT pg_get_userbyid(nspowner) INTO owner_role FROM pg_namespace WHERE nspname = '${orchestratorSchema}'; + IF target_role.rolsuper OR target_role.rolcreaterole OR target_role.rolcreatedb + OR target_role.rolreplication OR target_role.rolbypassrls + OR pg_has_role(runtime_role, owner_role, 'MEMBER') THEN + RAISE EXCEPTION 'Runtime role must be separate and unprivileged' USING ERRCODE = '42501'; + END IF; + EXECUTE format('GRANT USAGE ON SCHEMA %I TO %I', '${orchestratorSchema}', runtime_role); + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.flow_definition TO %I', '${orchestratorSchema}', runtime_role); + EXECUTE format('GRANT SELECT, UPDATE ON %I.request_type TO %I', '${orchestratorSchema}', runtime_role); + EXECUTE format('GRANT SELECT, INSERT, UPDATE ON %I.request_instance, %I.execution, %I.action_execution TO %I', + '${orchestratorSchema}', '${orchestratorSchema}', '${orchestratorSchema}', runtime_role); + EXECUTE format('GRANT SELECT, INSERT ON %I.document_reference, %I.audit_entry, %I.admin_audit, %I.request_creation_idempotency, %I.reconciliation_resolution TO %I', + '${orchestratorSchema}', '${orchestratorSchema}', '${orchestratorSchema}', '${orchestratorSchema}', '${orchestratorSchema}', runtime_role); +END; +$$; +REVOKE ALL ON FUNCTION "${orchestratorSchema}".grant_runtime_access(name) FROM PUBLIC; diff --git a/src/main/resources/db/migration/V3__action_effect_checkpoint.sql b/src/main/resources/db/migration/V3__action_effect_checkpoint.sql new file mode 100644 index 0000000..f865a1d --- /dev/null +++ b/src/main/resources/db/migration/V3__action_effect_checkpoint.sql @@ -0,0 +1,5 @@ +-- Persist trusted effect classification so crash recovery does not infer safety from editable flow data. +-- Legacy checkpoints are conservatively unsafe until explicitly reconciled. +ALTER TABLE "${orchestratorSchema}".action_execution + ADD COLUMN effect varchar(30) NOT NULL DEFAULT 'NON_IDEMPOTENT_WRITE' + CHECK (effect IN ('READ_ONLY','IDEMPOTENT_WRITE','NON_IDEMPOTENT_WRITE')); diff --git a/src/test/java/com/organizacion/compatibility/Postgres96CompatibilityProbe.java b/src/test/java/com/organizacion/compatibility/Postgres96CompatibilityProbe.java index bd529ff..82c3996 100644 --- a/src/test/java/com/organizacion/compatibility/Postgres96CompatibilityProbe.java +++ b/src/test/java/com/organizacion/compatibility/Postgres96CompatibilityProbe.java @@ -45,7 +45,9 @@ class Postgres96CompatibilityProbe { // No migration initializer exists in this probe. Only validate() is invoked, // using the same read-only pool; schema creation is explicitly forbidden. Flyway.configure().dataSource(context.getBean(DataSource.class)) - .locations("classpath:db/migration").createSchemas(false).load().validate(); + .locations("classpath:db/migration").defaultSchema("orchestrator") + .schemas("orchestrator").placeholders(java.util.Map.of("orchestratorSchema", "orchestrator")) + .createSchemas(false).load().validate(); var sessionFactory = context.getBean(EntityManagerFactory.class).unwrap(SessionFactoryImplementor.class); assertEquals("org.hibernate.community.dialect.PostgreSQLLegacyDialect", sessionFactory.getJdbcServices().getDialect().getClass().getName()); diff --git a/src/test/java/com/organizacion/orchestrator/AvailableEventPolicyTest.java b/src/test/java/com/organizacion/orchestrator/AvailableEventPolicyTest.java new file mode 100644 index 0000000..45f4dfd --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/AvailableEventPolicyTest.java @@ -0,0 +1,42 @@ +package com.organizacion.orchestrator; +import com.organizacion.orchestrator.request.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class AvailableEventPolicyTest { + @Test void documentFixtureKeepsVerifiedFieldsSeparateFromClientInput(){ + var codec=FlowFixtures.CODEC;var report=new com.organizacion.orchestrator.flow.infrastructure.ConfiguredFlowValidator(HttpProviderFixture.exampleCatalog(),codec).validate(codec.decode(DocumentTasksIT.documentFlow().toString())); + assertTrue(report.valid(),report.toString()); + } + static final Actor ACTOR=new Actor("owner","scope-a",Set.of("ANALISTA")); + static AvailableEventsView view(String guard,ObjectValue data,String state,Set roles,Optional active){ + var n=FlowFixtures.simple(); + if(guard!=null)((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0")).set("guard",FlowFixtures.node(guard)); + return new AvailableEventPolicy().project(new AvailabilitySnapshot(UUID.randomUUID(),7,state,data,FlowFixtures.CODEC.decode(n.toString()),active),new Actor("owner","scope-a",roles)); + } + @Test void activeExecutionHidesAllTasksAndGuards(){ + UUID active=UUID.randomUUID();var v=view(null,ObjectValue.empty(),"START",ACTOR.roles(),Optional.of(active)); + assertEquals(Optional.of(active),v.blockedByExecutionId());assertTrue(v.events().isEmpty());assertTrue(v.blocked().isEmpty()); + } + @Test void filtersRolesAndTerminalState(){ + assertTrue(view(null,ObjectValue.empty(),"START",Set.of("FLOW_ADMIN"),Optional.empty()).events().isEmpty()); + assertTrue(view(null,ObjectValue.empty(),"END",ACTOR.roles(),Optional.empty()).events().isEmpty()); + assertEquals("SUBMIT",view(null,ObjectValue.empty(),"START",ACTOR.roles(),Optional.empty()).events().getFirst().event()); + } + @Test void exposesOnlySafeCodesForKnownFalseOrInvalidGuards(){ + var no=view("{\"op\":\"EQ\",\"left\":{\"literal\":1},\"right\":{\"literal\":2}}",ObjectValue.empty(),"START",ACTOR.roles(),Optional.empty()); + assertTrue(no.events().isEmpty());assertEquals("GUARD_NOT_SATISFIED",no.blocked().getFirst().reasonCode()); + var missing=view("{\"op\":\"EQ\",\"left\":{\"path\":\"/request/data/client\"},\"right\":{\"literal\":\"C1\"}}",ObjectValue.empty(),"START",ACTOR.roles(),Optional.empty()); + assertEquals("EVALUATION_ERROR",missing.blocked().getFirst().reasonCode()); + } + @Test void guardsDependingOnFutureInputAreDeferredIncludingExistsAndNot(){ + for(String guard:List.of("{\"op\":\"EXISTS\",\"value\":{\"path\":\"/event/data/documentId\"}}", + "{\"op\":\"NOT\",\"arg\":{\"op\":\"EXISTS\",\"value\":{\"path\":\"/event/data/documentId\"}}}", + "{\"op\":\"EQ\",\"left\":{\"path\":\"/event/data/choice\"},\"right\":{\"literal\":true}}")){ + var v=view(guard,ObjectValue.empty(),"START",ACTOR.roles(),Optional.empty());assertEquals(1,v.events().size());assertTrue(v.blocked().isEmpty()); + } + } +} diff --git a/src/test/java/com/organizacion/orchestrator/AvailableEventsIT.java b/src/test/java/com/organizacion/orchestrator/AvailableEventsIT.java new file mode 100644 index 0000000..4123eb4 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/AvailableEventsIT.java @@ -0,0 +1,81 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.flow.domain.FlowVersion; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.net.http.HttpResponse; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; + +class AvailableEventsIT { + @BeforeAll static void setup()throws Exception{RequestCreationIT.setup();WorkerIT.store=fresh();} + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @BeforeEach void isolate(){new WorkerIT().isolateQueue();} + static ObjectNode form(String title){ + var n=FlowFixtures.simple();n.put("flowKey",unique()); + var task=n.withArray("tasks").addObject().put("state","START").put("event","SUBMIT"); + task.set("inputSchema",n.at("/events/0/inputSchema").deepCopy()); + task.putObject("ui").put("component","FORM").put("title",title).putArray("fieldOrder");return n; + } + static UUID create(FlowVersion flow,String data)throws Exception{ + var r=RequestCreationIT.post(RequestCreationIT.type(flow),unique(),data,RequestCreationIT.jwt());assertEquals(201,r.statusCode(),r.body());return UUID.fromString(json.readTree(r.body()).get("id").asText()); + } + static HttpResponse available(UUID id,String jwt)throws Exception{return call("GET","/api/v1/requests/"+id+"/available-events",jwt,null,null);} + static com.fasterxml.jackson.databind.JsonNode available(UUID id)throws Exception{ + var r=available(id,RequestCreationIT.jwt());assertEquals(200,r.statusCode(),r.body());RequestCreationIT.contract(r,"AvailableEvents");return json.readTree(r.body()); + } + @Test void returnsFormSchemaWithoutCachingAndEnforcesOwnershipAndRoles()throws Exception{ + UUID id=create(publish(form("Solicitud")),"{}");var response=available(id,RequestCreationIT.jwt());RequestCreationIT.contract(response,"AvailableEvents"); + assertTrue(response.headers().firstValue("ETag").isEmpty());assertEquals("no-store",response.headers().firstValue("Cache-Control").orElseThrow()); + var n=json.readTree(response.body());assertEquals("FORM",n.at("/events/0/ui/component").asText());assertEquals("Solicitud",n.at("/events/0/ui/title").asText()); + assertFalse(response.body().contains("allowedRoles"));assertFalse(response.body().contains("actions")); + assertEquals(401,available(id,null).statusCode()); + assertEquals(404,available(UUID.randomUUID(),RequestCreationIT.jwt()).statusCode()); + for(String jwt:List.of(token("ANALISTA","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner")))assertEquals(404,available(id,jwt).statusCode()); + var denied=json.readTree(available(id,token("FLOW_ADMIN","scope-a","")).body());assertTrue(denied.get("events").isEmpty());assertTrue(denied.get("blocked").isEmpty()); + } + @ParameterizedTest @ValueSource(strings={"PENDING","RUNNING","RETRY_WAIT","RECONCILIATION_REQUIRED"}) + void everyActiveExecutionSuppressesTasks(String state)throws Exception{ + UUID request=create(publish(form("Formulario")),"{}");var accepted=EventAcceptanceIT.event(request,unique(),0);assertEquals(202,accepted.statusCode()); + UUID id=UUID.fromString(json.readTree(accepted.body()).get("executionId").asText()); + if(state.equals("RUNNING"))claim("test"); + else admin.update("UPDATE "+schema+".execution SET status=? WHERE id=?",state,id); + var view=available(request);assertEquals(id.toString(),view.get("blockedByExecutionId").asText());assertEquals(1,view.get("revision").asLong()); + assertTrue(view.get("events").isEmpty());assertTrue(view.get("blocked").isEmpty()); + assertEquals(409,EventAcceptanceIT.event(request,unique(),1).statusCode()); + } + @Test void knownGuardsAreBlockedButInputGuardIsRevalidatedAtPost()throws Exception{ + var n=form("Confirmar");((ObjectNode)n.at("/transitions/0")).set("guard",FlowFixtures.node("{\"op\":\"EQ\",\"left\":{\"path\":\"/request/data/client\"},\"right\":{\"literal\":\"allowed\"}}")); + var flow=publish(n);UUID denied=create(flow,"{\"client\":\"denied\"}"),missing=create(flow,"{}"),allowed=create(flow,"{\"client\":\"allowed\"}"); + assertEquals("GUARD_NOT_SATISFIED",available(denied).at("/blocked/0/reasonCode").asText()); + assertEquals("EVALUATION_ERROR",available(missing).at("/blocked/0/reasonCode").asText());assertEquals(1,available(allowed).get("events").size()); + n=form("Consentimiento");var input=FlowFixtures.node("{\"type\":\"object\",\"properties\":{\"agree\":{\"type\":\"boolean\"}},\"required\":[\"agree\"],\"additionalProperties\":false}"); + ((ObjectNode)n.at("/events/0")).set("inputSchema",input);((ObjectNode)n.at("/tasks/0")).set("inputSchema",input); + ((ObjectNode)n.at("/transitions/0")).set("guard",FlowFixtures.node("{\"op\":\"EQ\",\"left\":{\"path\":\"/event/data/agree\"},\"right\":{\"literal\":true}}")); + UUID id=create(publish(n),"{}");assertEquals(1,available(id).get("events").size()); + assertEquals(422,EventAcceptanceIT.event(id,unique(),"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"agree\":false}}",RequestCreationIT.jwt()).statusCode()); + assertEquals(202,EventAcceptanceIT.event(id,unique(),"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"agree\":true}}",RequestCreationIT.jwt()).statusCode()); + } + @Test void retiredPinnedVersionKeepsOriginalTaskAfterActivation()throws Exception{ + var first=publish(form("Versión uno"));String type=RequestCreationIT.type(first); + var response=RequestCreationIT.post(type,unique(),"{}",RequestCreationIT.jwt());UUID older=UUID.fromString(json.readTree(response.body()).get("id").asText()); + var n=form("Versión dos");n.put("flowKey",first.definition().flowKey()).put("version",2);var second=publish(n); + RequestCreationIT.flows.activate(type,second.id(),1,RequestCreationIT.manager,"activate");RequestCreationIT.flows.retire(first.id(),first.revision(),RequestCreationIT.manager,"retire"); + response=RequestCreationIT.post(type,unique(),"{}",RequestCreationIT.jwt());UUID newer=UUID.fromString(json.readTree(response.body()).get("id").asText()); + assertEquals("Versión uno",available(older).at("/events/0/ui/title").asText());assertEquals("Versión dos",available(newer).at("/events/0/ui/title").asText()); + } + @Test void uncommittedTransitionCannotMixRevisionWithActiveExecution()throws Exception{ + UUID request=create(publish(form("Formulario")),"{}");var accepted=EventAcceptanceIT.event(request,unique(),0);UUID execution=UUID.fromString(json.readTree(accepted.body()).get("executionId").asText()); + try(var connection=admin.getDataSource().getConnection()){ + connection.setAutoCommit(false); + try(var update=connection.prepareStatement("UPDATE "+schema+".request_instance SET current_state='END',revision=2 WHERE id=?")){update.setObject(1,request);update.executeUpdate();} + try(var update=connection.prepareStatement("UPDATE "+schema+".execution SET status='SUCCEEDED',result='{}'::jsonb WHERE id=?")){update.setObject(1,execution);update.executeUpdate();} + var during=available(request);assertEquals(1,during.get("revision").asLong());assertEquals(execution.toString(),during.get("blockedByExecutionId").asText());connection.commit(); + } + var after=available(request);assertEquals(2,after.get("revision").asLong());assertFalse(after.has("blockedByExecutionId"));assertTrue(after.get("events").isEmpty()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/CrashWorkerProcess.java b/src/test/java/com/organizacion/orchestrator/CrashWorkerProcess.java new file mode 100644 index 0000000..8646d30 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/CrashWorkerProcess.java @@ -0,0 +1,28 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.execution.infrastructure.*; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.net.URI; +import java.net.http.*; +import java.time.Duration; + +/** Test-only child JVM: abruptly terminates at a controlled effect boundary. Never packaged. */ +public final class CrashWorkerProcess { + public static void main(String[] args)throws Exception{ + var env=System.getenv();var source=new DriverManagerDataSource(env.get("TEST_JDBC"),env.get("TEST_USER"),env.get("TEST_PASSWORD")); + var store=new PostgresWorkerStore(source,FlowFixtures.CODEC,env.get("TEST_SCHEMA")); + var token=store.claim("crashing-child",1,Duration.ofSeconds(60)).getFirst(); + var actions=WorkerIT.actions(ActionCapability.Effect.valueOf(args[0]),(a,c)->{ + if(args[1].equals("BEFORE_SEND"))Runtime.getRuntime().halt(23); + try(var http=HttpClient.newHttpClient()){ + var response=http.send(HttpRequest.newBuilder(URI.create(args[2])).header("Idempotency-Key",c.externalIdempotencyKey()).POST(HttpRequest.BodyPublishers.ofString("{}")).build(),HttpResponse.BodyHandlers.ofString()); + if(response.statusCode()!=200)throw new IllegalStateException("Mock failed"); + }catch(Exception e){throw new IllegalStateException(e);} + if(args[1].equals("AFTER_CHECKPOINT")&&a.id().equals("lookup"))return WorkerIT.ok(70); + Runtime.getRuntime().halt(23);return WorkerIT.ok(70); + }); + new WorkerEngine(store,actions,FlowFixtures.CODEC).run(token); + throw new AssertionError("Crash point not reached"); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DatabaseMigrationIT.java b/src/test/java/com/organizacion/orchestrator/DatabaseMigrationIT.java new file mode 100644 index 0000000..b699e10 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DatabaseMigrationIT.java @@ -0,0 +1,458 @@ +package com.organizacion.orchestrator; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.FlywayException; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.postgresql.PostgreSQLContainer; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.util.*; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** Same migrations/tests on an ephemeral container or the explicitly authorized test database. */ +class DatabaseMigrationIT { + private static final String HASH = "a".repeat(64); + private static final String SCHEMA = "t03_" + UUID.randomUUID().toString().replace("-", ""); + private static PostgreSQLContainer container; + private static String url; + private static String user; + private static String password; + private static boolean schemaCreated; + private static boolean external; + private Connection connection; + private Fixture fixture; + + private record Fixture(UUID flow, UUID request) {} + + @BeforeAll + static void migrateFreshSchema() throws Exception { + external = Boolean.getBoolean("migration.test.external"); + if (external) { + url = requiredEnv("MIGRATION_TEST_DB_URL"); + // No arbitrary database, credentials in URL, query parameters, or production DB fallback. + assertTrue(url.matches("jdbc:postgresql://[A-Za-z0-9.-]+:[0-9]+/orquestador_test"), + "External migration tests require an explicit URL ending exactly in /orquestador_test"); + user = requiredEnv("MIGRATION_TEST_DB_USER"); + password = requiredEnv("MIGRATION_TEST_DB_PASSWORD"); + } else { + container = new PostgreSQLContainer("postgres:9.6.24") + .withDatabaseName("orquestador_test").withUsername("migration_owner").withPassword("test-only-password"); + container.start(); // No disabledWithoutDocker or silent fallback. + url = container.getJdbcUrl(); user = container.getUsername(); password = container.getPassword(); + } + try (Connection c = connect()) { + assertEquals("orquestador_test", scalar(c, "SELECT current_database()")); + assertTrue(SCHEMA.matches("t03_[a-f0-9]{32}")); + execute(c, "CREATE SCHEMA \"" + SCHEMA + "\""); + schemaCreated = true; + } + var result = flyway().migrate(); + assertEquals(3, result.migrationsExecuted, "All migrations must run from scratch"); + assertEquals("3", result.targetSchemaVersion); + } + + @AfterAll + static void removeOnlyOwnedTestSchema() throws Exception { + try { + if (schemaCreated) { + try (Connection c = connect()) { + assertEquals("orquestador_test", scalar(c, "SELECT current_database()")); + assertTrue(SCHEMA.matches("t03_[a-f0-9]{32}")); + execute(c, "DROP SCHEMA \"" + SCHEMA + "\" CASCADE"); + } + } + } finally { + if (container != null) container.stop(); + } + } + + @BeforeEach + void beginTransaction() throws Exception { + connection = connect(); connection.setAutoCommit(false); + fixture = seed(connection); + } + + @AfterEach + void rollbackTestDataAndTemporaryRoles() throws Exception { + if (connection != null) { + try { connection.rollback(); } finally { connection.close(); } + } + } + + private static String requiredEnv(String key) { + String value = System.getenv(key); + assertNotNull(value, "Missing " + key); + assertFalse(value.isBlank(), "Empty " + key); + return value; + } + + private static Connection connect() throws SQLException { + var p = new Properties(); + p.setProperty("user", user); p.setProperty("password", password); + p.setProperty("connectTimeout", "10"); p.setProperty("socketTimeout", "20"); + p.setProperty("ApplicationName", "orquestador-t03-isolated-tests"); + Connection c = DriverManager.getConnection(url, p); + execute(c, "SET statement_timeout = '15s'"); + execute(c, "SET lock_timeout = '5s'"); + execute(c, "SET TIME ZONE 'UTC'"); + return c; + } + + private static Flyway flyway(String... extraLocations) { + var locations = new ArrayList<>(List.of("classpath:db/migration")); + locations.addAll(Arrays.asList(extraLocations)); + return Flyway.configure().dataSource(url, user, password).schemas(SCHEMA).defaultSchema(SCHEMA) + .createSchemas(false).cleanDisabled(true).placeholders(Map.of("orchestratorSchema", SCHEMA)) + .locations(locations.toArray(String[]::new)).load(); + } + + private static String table(String name) { return '"' + SCHEMA + "\"." + name; } + + private static int execute(Connection c, String sql, Object... parameters) throws SQLException { + try (var statement = c.prepareStatement(sql)) { + statement.setQueryTimeout(15); + for (int i = 0; i < parameters.length; i++) statement.setObject(i + 1, parameters[i]); + return statement.executeUpdate(); + } + } + + private static String scalar(Connection c, String sql, Object... parameters) throws SQLException { + try (var statement = c.prepareStatement(sql)) { + statement.setQueryTimeout(15); + for (int i = 0; i < parameters.length; i++) statement.setObject(i + 1, parameters[i]); + try (var result = statement.executeQuery()) { + assertTrue(result.next(), sql); + return result.getString(1); + } + } + } + + private void rejected(String state, String sql, Object... parameters) throws Exception { + var savepoint = connection.setSavepoint(); + try { + var error = assertThrows(SQLException.class, () -> execute(connection, sql, parameters)); + assertEquals(state, error.getSQLState(), error.getMessage()); + } finally { connection.rollback(savepoint); connection.releaseSavepoint(savepoint); } + } + + private static UUID draft(Connection c) throws SQLException { + UUID id = UUID.randomUUID(); + execute(c, "INSERT INTO " + table("flow_definition") + + " (id,flow_key,version,status,schema_version,definition,created_by) VALUES (?, ?, 1,'DRAFT',1,'{}','editor')", id, "FLOW_" + id); + return id; + } + + private static Fixture seed(Connection c) throws SQLException { + UUID flow = draft(c), request = UUID.randomUUID(); + execute(c, "UPDATE " + table("flow_definition") + + " SET status='PUBLISHED',checksum=?,published_at=now(),revision=revision+1 WHERE id=?", HASH, flow); + String type = "TYPE_" + request; + execute(c, "INSERT INTO " + table("request_type") + "(type_key,active_flow_id) VALUES (?,?)", type, flow); + execute(c, "INSERT INTO " + table("request_instance") + + "(id,type_key,flow_id,current_state,owner_subject,business_scope) VALUES (?,?,?,'CREADA','actor','scope')", request, type, flow); + return new Fixture(flow, request); + } + + private static UUID pending(Connection c, UUID request, String key) throws SQLException { + UUID id = UUID.randomUUID(); + execute(c, "INSERT INTO " + table("execution") + + "(id,request_id,idempotency_key,payload_hash,actor_subject,actor_snapshot,event_key,event_data,transition_id,from_state,accepted_revision,status)" + + " VALUES (?,?,?,?,'actor','{}','EVENT','{}','transition','CREADA',0,'PENDING')", id, request, key, HASH); + return id; + } + + private UUID audit(UUID execution) throws SQLException { + UUID id = UUID.randomUUID(); + execute(connection, "INSERT INTO " + table("audit_entry") + + "(id,request_id,execution_id,kind,actor_subject,flow_id,detail) VALUES (?,?,?,'EVENT_ACCEPTED','actor',?,'{}')", + id, fixture.request, execution, fixture.flow); + return id; + } + + @Test + void migrationsValidateAndSecondRunIsIdempotent() { + flyway().validate(); + assertEquals(0, flyway().migrate().migrationsExecuted); + assertEquals(3, flyway().info().applied().length); + } + + @Test + void schemaHasAllTenTablesAndOperationalIndexes() throws Exception { + assertEquals("10", scalar(connection, "SELECT count(*) FROM information_schema.tables WHERE table_schema=? AND table_name<>'flyway_schema_history'", SCHEMA)); + for (String index : List.of("uq_request_active_execution", "ix_execution_due", "ix_execution_lease", "ix_request_scope_state", "ix_audit_request", "ix_admin_audit_flow")) { + assertEquals("1", scalar(connection, "SELECT count(*) FROM pg_indexes WHERE schemaname=? AND indexname=?", SCHEMA, index)); + } + assertTrue(scalar(connection, "SELECT indexdef FROM pg_indexes WHERE schemaname=? AND indexname='uq_request_active_execution'", SCHEMA).contains("RECONCILIATION_REQUIRED")); + } + + @Test + void foreignKeysRejectOrphanAndMismatchedAuditReferences() throws Exception { + rejected("23503", "INSERT INTO " + table("request_type") + "(type_key,active_flow_id) VALUES ('ORPHAN',?)", UUID.randomUUID()); + UUID execution = pending(connection, fixture.request, "audit"); + Fixture other = seed(connection); + rejected("23503", "INSERT INTO " + table("audit_entry") + + "(id,request_id,execution_id,kind,actor_subject,flow_id,detail) VALUES (?,?,?,'EVENT','actor',?,'{}')", + UUID.randomUUID(), other.request, execution, other.flow); + rejected("23503", "INSERT INTO " + table("audit_entry") + + "(id,request_id,kind,actor_subject,flow_id,detail) VALUES (?,?,'EVENT','actor',?,'{}')", + UUID.randomUUID(), fixture.request, other.flow); + rejected("23503", "INSERT INTO " + table("document_reference") + + "(id,request_id,document_type,storage_document_id,mime_type,size_bytes,checksum,created_by) VALUES (?,?,'INE','doc','image/png',1,'hash','actor')", + UUID.randomUUID(), UUID.randomUUID()); + } + + @ParameterizedTest + @ValueSource(strings = {"definition='[]'::jsonb", "definition='null'::jsonb", "schema_version=0", "revision=-1"}) + void invalidDraftDataIsRejected(String mutation) throws Exception { + UUID id = draft(connection); + // revision has a lifecycle trigger; other values advance it correctly before CHECK validation. + String revision = mutation.startsWith("revision") ? "" : ", revision=revision+1"; + rejected(mutation.startsWith("revision") ? "55000" : "23514", "UPDATE " + table("flow_definition") + " SET " + mutation + revision + " WHERE id=?", id); + } + + @ParameterizedTest + @ValueSource(strings = {"data='[]'::jsonb", "data='null'::jsonb", "revision=-1", "owner_subject=''", "business_scope=''"}) + void invalidRequestDataIsRejected(String mutation) throws Exception { + rejected("23514", "UPDATE " + table("request_instance") + " SET " + mutation + " WHERE id=?", fixture.request); + } + + @Test + void flowKeyVersionIsUniqueAndReferencedDraftCannotBeDeleted() throws Exception { + UUID id = draft(connection); + rejected("23505", "INSERT INTO " + table("flow_definition") + + "(id,flow_key,version,status,schema_version,definition,created_by) VALUES (?, ?, 1,'DRAFT',1,'{}','editor')", UUID.randomUUID(), "FLOW_" + id); + execute(connection, "INSERT INTO " + table("request_type") + "(type_key,active_flow_id) VALUES ('REFERENCE',?)", id); + rejected("23503", "DELETE FROM " + table("flow_definition") + " WHERE id=?", id); + } + + @ParameterizedTest + @ValueSource(strings = {"definition='{\"changed\":true}'::jsonb", "flow_key='ALTERED'", "version=2", "schema_version=2", + "checksum='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'", "status='DRAFT'", "created_by='other'", "published_at=now()+interval '1 second'"}) + void publishedDefinitionIsImmutable(String mutation) throws Exception { + rejected("55000", "UPDATE " + table("flow_definition") + " SET " + mutation + ",revision=revision+1 WHERE id=?", fixture.flow); + } + + @Test + void lifecycleAllowsOnlyDraftPublishRetireWithRevisionAndMetadata() throws Exception { + UUID id = draft(connection); + rejected("23514", "UPDATE " + table("flow_definition") + " SET status='PUBLISHED',revision=revision+1 WHERE id=?", id); + rejected("55000", "UPDATE " + table("flow_definition") + " SET status='RETIRED',revision=revision+1 WHERE id=?", id); + rejected("55000", "UPDATE " + table("flow_definition") + " SET definition='{\"a\":1}' WHERE id=?", id); + execute(connection, "UPDATE " + table("flow_definition") + " SET definition='{\"a\":1}',revision=revision+1 WHERE id=?", id); + execute(connection, "UPDATE " + table("flow_definition") + " SET status='RETIRED',revision=revision+1 WHERE id=?", fixture.flow); + assertEquals("RETIRED", scalar(connection, "SELECT status FROM " + table("flow_definition") + " WHERE id=?", fixture.flow)); + assertEquals(fixture.flow.toString(), scalar(connection, "SELECT flow_id FROM " + table("request_instance") + " WHERE id=?", fixture.request)); + rejected("55000", "UPDATE " + table("flow_definition") + " SET status='PUBLISHED',revision=revision+1 WHERE id=?", fixture.flow); + rejected("55000", "DELETE FROM " + table("flow_definition") + " WHERE id=?", fixture.flow); + rejected("55000", "INSERT INTO " + table("flow_definition") + + "(id,flow_key,version,status,schema_version,definition,checksum,published_at,created_by) VALUES (?,'ILLEGAL',1,'PUBLISHED',1,'{}',?,now(),'actor')", UUID.randomUUID(), HASH); + } + + @Test + void publishedDeleteAndTruncateAreRejectedEvenForOwner() throws Exception { + rejected("55000", "DELETE FROM " + table("flow_definition") + " WHERE id=?", fixture.flow); + rejected("55000", "TRUNCATE " + table("flow_definition") + " CASCADE"); + } + + @ParameterizedTest + @ValueSource(strings = {"PENDING", "RUNNING", "RETRY_WAIT", "RECONCILIATION_REQUIRED"}) + void everyActiveStatusBlocksSecondExecution(String status) throws Exception { + UUID id = pending(connection, fixture.request, "first"); + if (status.equals("RUNNING")) { + execute(connection, "UPDATE " + table("execution") + " SET status='RUNNING',lease_owner='worker',lease_until=now()+interval '60 seconds',fencing_token=1 WHERE id=?", id); + } else execute(connection, "UPDATE " + table("execution") + " SET status=? WHERE id=?", status, id); + var point = connection.setSavepoint(); + try { assertEquals("23505", assertThrows(SQLException.class, () -> pending(connection, fixture.request, "second")).getSQLState()); } + finally { connection.rollback(point); } + } + + @Test + void terminalExecutionReleasesSlotButKeepsIdempotencyKey() throws Exception { + UUID id = pending(connection, fixture.request, "original"); + execute(connection, "UPDATE " + table("execution") + " SET status='FAILED' WHERE id=?", id); + var point = connection.setSavepoint(); + try { assertEquals("23505", assertThrows(SQLException.class, () -> pending(connection, fixture.request, "original")).getSQLState()); } + finally { connection.rollback(point); } + assertNotNull(pending(connection, fixture.request, "new")); + } + + @Test + void leasesCountersHashesAndResultsHaveChecks() throws Exception { + UUID id = pending(connection, fixture.request, "checks"); + for (String mutation : List.of("status='UNKNOWN'", "status='RUNNING'", "attempts=-1", "accepted_revision=-1", + "fencing_token=-1", "payload_hash='invalid'", "event_data='[]'::jsonb", "actor_snapshot='null'::jsonb", + "status='SUCCEEDED'", "result='[]'::jsonb", "lease_owner='worker'")) { + rejected("23514", "UPDATE " + table("execution") + " SET " + mutation + " WHERE id=?", id); + } + } + + @Test + void actionCheckpointAndExternalKeyAreUnique() throws Exception { + UUID execution = pending(connection, fixture.request, "action"); + UUID action = UUID.randomUUID(); + String sql = "INSERT INTO " + table("action_execution") + "(id,execution_id,action_id,status,external_idempotency_key,input_hash) VALUES (?, ?, ?, 'PENDING', ?, ?)"; + execute(connection, sql, action, execution, "lookup", "external-key", HASH); + rejected("23505", sql, UUID.randomUUID(), execution, "lookup", "other-key", HASH); + rejected("23505", sql, UUID.randomUUID(), execution, "other", "external-key", HASH); + rejected("23514", "UPDATE " + table("action_execution") + " SET status='SUCCEEDED' WHERE id=?", action); + rejected("23514", "UPDATE " + table("action_execution") + " SET attempt_count=-1 WHERE id=?", action); + execute(connection, "UPDATE " + table("action_execution") + " SET status='SUCCEEDED',result='{}',started_at=now(),finished_at=now() WHERE id=?", action); + } + + @Test + void documentReferenceChecksAndUniqueness() throws Exception { + String sql = "INSERT INTO " + table("document_reference") + + "(id,request_id,document_type,storage_document_id,mime_type,size_bytes,checksum,created_by) VALUES (?,?,'INE','doc','image/png',?,'hash','actor')"; + rejected("23514", sql, UUID.randomUUID(), fixture.request, -1); + execute(connection, sql, UUID.randomUUID(), fixture.request, 0); + rejected("23505", sql, UUID.randomUUID(), fixture.request, 0); + } + + private UUID creation(String actor, String scope) throws SQLException { + UUID id = UUID.randomUUID(); + execute(connection, "INSERT INTO " + table("request_creation_idempotency") + + "(id,actor_subject,business_scope,idempotency_key,payload_hash,request_id,response) VALUES (?,?,?,'create-key',?,?,'{}')", + id, actor, scope, HASH, fixture.request); + return id; + } + + @Test + void creationIdempotencyIsScopedAndImmutable() throws Exception { + UUID id = creation("actor", "scope"); + var point = connection.setSavepoint(); + try { assertEquals("23505", assertThrows(SQLException.class, () -> creation("actor", "scope")).getSQLState()); } + finally { connection.rollback(point); } + creation("actor", "other-scope"); creation("other-actor", "scope"); + rejected("55000", "UPDATE " + table("request_creation_idempotency") + " SET response='{\"changed\":true}' WHERE id=?", id); + rejected("55000", "DELETE FROM " + table("request_creation_idempotency") + " WHERE id=?", id); + } + + @Test + void reconciliationResolutionIsIdempotentAndAppendOnly() throws Exception { + UUID execution = pending(connection, fixture.request, "reconcile"); + execute(connection, "UPDATE " + table("execution") + " SET status='RECONCILIATION_REQUIRED' WHERE id=?", execution); + String sql = "INSERT INTO " + table("reconciliation_resolution") + + "(id,execution_id,idempotency_key,payload_hash,mode,actor_subject,evidence,verified_actions,result) VALUES (?,?,'resolution-key',?,'RESUME','operator','{\"reference\":\"CASE-1\"}','[{\"actionId\":\"lookup\",\"result\":{}}]','{}')"; + UUID resolution = UUID.randomUUID(); + execute(connection, sql, resolution, execution, HASH); + rejected("23505", sql, UUID.randomUUID(), execution, HASH); + rejected("55000", "UPDATE " + table("reconciliation_resolution") + " SET result='{\"changed\":true}' WHERE id=?", resolution); + rejected("55000", "DELETE FROM " + table("reconciliation_resolution") + " WHERE id=?", resolution); + rejected("23514", "INSERT INTO " + table("reconciliation_resolution") + + "(id,execution_id,idempotency_key,payload_hash,mode,actor_subject,evidence,result) VALUES (?,?,'bad',?,'CLOSE_WITHOUT_TRANSITION','operator','{\"reference\":\"CASE-1\"}','{}')", + UUID.randomUUID(), execution, HASH); + } + + @Test + void auditHistoryCannotBeUpdatedDeletedOrTruncated() throws Exception { + UUID id = audit(null); + rejected("55000", "UPDATE " + table("audit_entry") + " SET detail='{\"changed\":true}' WHERE id=?", id); + rejected("55000", "DELETE FROM " + table("audit_entry") + " WHERE id=?", id); + UUID admin = UUID.randomUUID(); + execute(connection, "INSERT INTO " + table("admin_audit") + "(id,flow_id,action,actor_subject,correlation_id) VALUES (?,?,'PUBLISH','editor','correlation')", admin, fixture.flow); + rejected("55000", "UPDATE " + table("admin_audit") + " SET action='RETIRE' WHERE id=?", admin); + rejected("55000", "DELETE FROM " + table("admin_audit") + " WHERE id=?", admin); + for (String name : List.of("audit_entry", "admin_audit", "request_creation_idempotency", "reconciliation_resolution")) { + rejected("55000", "TRUNCATE " + table(name)); + } + } + + @Test + void rollbackKeepsBusinessExecutionAndAuditAtomic() throws Exception { + var point = connection.setSavepoint(); + UUID execution = pending(connection, fixture.request, "rollback"); + audit(execution); + execute(connection, "UPDATE " + table("request_instance") + " SET current_state='NEXT',revision=revision+1 WHERE id=?", fixture.request); + connection.rollback(point); + assertEquals("CREADA", scalar(connection, "SELECT current_state FROM " + table("request_instance") + " WHERE id=?", fixture.request)); + assertEquals("0", scalar(connection, "SELECT count(*) FROM " + table("execution") + " WHERE id=?", execution)); + assertEquals("0", scalar(connection, "SELECT count(*) FROM " + table("audit_entry") + " WHERE request_id=?", fixture.request)); + } + + @Test + void failedMigrationRollsBackDdlAndHistory() throws Exception { + Path dir = Files.createTempDirectory(Path.of("target"), "migration-failure-"); + Path badMigration = dir.resolve("V999__intentional_failure.sql"); + Files.writeString(badMigration, "CREATE TABLE \"" + SCHEMA + "\".should_not_survive(id integer); SELECT 1/0;"); + try { + assertThrows(FlywayException.class, () -> flyway("filesystem:" + dir.toAbsolutePath()).migrate()); + assertNull(scalar(connection, "SELECT to_regclass(?)", SCHEMA + ".should_not_survive")); + assertEquals("0", scalar(connection, "SELECT count(*) FROM " + table("flyway_schema_history") + " WHERE version='999'")); + flyway().validate(); + } finally { Files.deleteIfExists(badMigration); Files.deleteIfExists(dir); } + } + + @Test + void runtimeRoleCanWorkButCannotAlterSchemaHistoryOrPrivileges() throws Exception { + String role = "t03_role_" + UUID.randomUUID().toString().replace("-", ""); + // PostgreSQL role creation and grants are transactional. AfterEach rolls all of them back. + execute(connection, "CREATE ROLE \"" + role + "\" NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS"); + scalar(connection, "SELECT " + table("grant_runtime_access") + "(?::name)", role); + execute(connection, "SET LOCAL ROLE \"" + role + "\""); + assertEquals("false", scalar(connection, "SELECT has_schema_privilege(current_user,?,'CREATE')::text", SCHEMA)); + assertEquals("false", scalar(connection, "SELECT has_table_privilege(current_user,?,'UPDATE')::text", SCHEMA + ".audit_entry")); + UUID execution = pending(connection, fixture.request, "runtime"); + audit(execution); + execute(connection, "UPDATE " + table("request_instance") + " SET revision=revision+1 WHERE id=?", fixture.request); + rejected("42501", "DELETE FROM " + table("audit_entry")); + rejected("42501", "UPDATE " + table("audit_entry") + " SET kind='OTHER'"); + rejected("42501", "CREATE TABLE " + table("forbidden") + "(id integer)"); + rejected("42501", "ALTER TABLE " + table("flow_definition") + " DISABLE TRIGGER ALL"); + rejected("42501", "DELETE FROM " + table("flyway_schema_history")); + rejected("55000", "UPDATE " + table("flow_definition") + " SET definition='{\"changed\":true}',revision=revision+1 WHERE id=?", fixture.flow); + var point = connection.setSavepoint(); + try { assertEquals("42501", assertThrows(SQLException.class, () -> scalar(connection, "SELECT " + table("grant_runtime_access") + "(?::name)", role)).getSQLState()); } + finally { connection.rollback(point); } + execute(connection, "RESET ROLE"); + connection.rollback(); + assertEquals("0", scalar(connection, "SELECT count(*) FROM pg_roles WHERE rolname=?", role)); + } + + @Test + void publicDoesNotReceiveSchemaTablesOrFunctionPrivileges() throws Exception { + assertEquals("0", scalar(connection, "SELECT count(*) FROM pg_namespace n, LATERAL aclexplode(coalesce(n.nspacl,acldefault('n',n.nspowner))) a WHERE n.nspname=? AND a.grantee=0", SCHEMA)); + assertEquals("0", scalar(connection, "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace, LATERAL aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) a WHERE n.nspname=? AND a.grantee=0", SCHEMA)); + assertEquals("0", scalar(connection, "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace, LATERAL aclexplode(coalesce(c.relacl,acldefault('r',c.relowner))) a WHERE n.nspname=? AND c.relkind='r' AND a.grantee=0", SCHEMA)); + } + + @Test + void skipLockedExcludesRowsClaimedByAnotherConnection() throws Exception { + UUID id; + try (var setup = connect()) { var data = seed(setup); id = pending(setup, data.request, "lock"); } + try (var first = connect(); var second = connect()) { + first.setAutoCommit(false); second.setAutoCommit(false); + assertEquals(id.toString(), scalar(first, "SELECT id FROM " + table("execution") + " WHERE id=? FOR UPDATE", id)); + assertEquals("0", scalar(second, "SELECT count(*) FROM (SELECT id FROM " + table("execution") + " WHERE id=? FOR UPDATE SKIP LOCKED) q", id)); + first.rollback(); + assertEquals("1", scalar(second, "SELECT count(*) FROM (SELECT id FROM " + table("execution") + " WHERE id=? FOR UPDATE SKIP LOCKED) q", id)); + second.rollback(); + } + } + + @Test + void concurrentActiveInsertsHaveExactlyOneWinner() throws Exception { + UUID request; + try (var setup = connect()) { request = seed(setup).request; } + CyclicBarrier barrier = new CyclicBarrier(2); + Callable insert = () -> { + try (var c = connect()) { + c.setAutoCommit(false); + barrier.await(5, TimeUnit.SECONDS); + try { pending(c, request, UUID.randomUUID().toString()); c.commit(); return "OK"; } + catch (SQLException e) { c.rollback(); return e.getSQLState(); } + } + }; + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(insert); var second = executor.submit(insert); + var outcomes = new HashSet<>(List.of(first.get(20, TimeUnit.SECONDS), second.get(20, TimeUnit.SECONDS))); + assertEquals(Set.of("OK", "23505"), outcomes); + } + assertEquals("1", scalar(connection, "SELECT count(*) FROM " + table("execution") + " WHERE request_id=?", request)); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DecisionEvaluatorTest.java b/src/test/java/com/organizacion/orchestrator/DecisionEvaluatorTest.java new file mode 100644 index 0000000..b3243fe --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DecisionEvaluatorTest.java @@ -0,0 +1,242 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.*; +import com.organizacion.orchestrator.decision.application.PureDecisionEvaluator; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; + +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class DecisionEvaluatorTest { + private static final ObjectValue EMPTY = ObjectValue.empty(); + private static final Actor ACTOR = new Actor("verified-subject", "verified-scope", Set.of("ANALISTA")); + private final PureDecisionEvaluator engine = new PureDecisionEvaluator(); + private final ConditionEvaluator conditions = new ConditionEvaluator(); + private static NumberValue number(String n) { return new NumberValue(new BigDecimal(n)); } + private static ValueExpression.Literal literal(JsonValue value) { return new ValueExpression.Literal(value); } + private static ValueExpression.Path path(String pointer) { return new ValueExpression.Path(pointer); } + private static Condition compare(Condition.Comparison op, JsonValue left, JsonValue right) { + return new Condition.Compare(op, literal(left), literal(right)); + } + private static EvaluationContext context(ObjectValue results) { return new EvaluationContext(EMPTY, EMPTY, results, ACTOR); } + private static Condition truth(boolean value) { return compare(Condition.Comparison.EQ, new BooleanValue(value), new BooleanValue(true)); } + private static Condition missing() { return new Condition.Compare(Condition.Comparison.GT, path("/results/absent"), literal(number("0"))); } + private static DecisionDefinition decision(DecisionDefinition.HitPolicy policy, Condition... rules) { + var list = new ArrayList(); + for (int i = 0; i < rules.length; i++) list.add(new DecisionDefinition.Rule("rule" + i, rules[i], "OUT" + i)); + return new DecisionDefinition("decision", policy, list, "DEFAULT"); + } + private static void failure(DecisionResult result, ErrorCode code) { + assertEquals(new DecisionResult.Failure(code), result); + } + private static void evaluationError(Runnable call) { + assertEquals(ErrorCode.EVALUATION_ERROR, assertThrows(DomainException.class, call::run).code()); + } + + /** Test-only mapping of the existing contractual scoring example; production DSL decoding is T06. */ + private static DecisionDefinition scoring() throws Exception { + var node = new ObjectMapper().readTree(Path.of("examples/contracts/flows/scoring.json").toFile()).path("decisions").get(0); + var rules = new ArrayList(); + for (var rule : node.path("rules")) + rules.add(new DecisionDefinition.Rule(rule.path("id").asText(), condition(rule.path("when")), rule.path("output").asText())); + return new DecisionDefinition(node.path("id").asText(), DecisionDefinition.HitPolicy.valueOf(node.path("hitPolicy").asText()), + rules, node.path("defaultOutput").asText()); + } + private static Condition condition(JsonNode node) { + String op = node.path("op").asText(); + if (op.equals("AND") || op.equals("OR")) { + var args = new ArrayList(); node.path("args").forEach(n -> args.add(condition(n))); + return new Condition.Logical(Condition.Junction.valueOf(op), args); + } + return new Condition.Compare(Condition.Comparison.valueOf(op), value(node.path("left")), value(node.path("right"))); + } + private static ValueExpression value(JsonNode node) { + return node.has("path") ? path(node.path("path").asText()) : literal(number(node.path("literal").asText())); + } + private static EvaluationContext score(JsonValue score) { + return context(new ObjectValue(Map.of("buro", new ObjectValue(Map.of("score", score))))); + } + + @ParameterizedTest + @CsvSource({"71,N", "70,M", "61,M", "60,MANUAL", "70.0000000000000000001,N", "60.0000000000000000001,M", "-100,MANUAL", "999,N"}) + void a04ScoringUsesActualContractExampleAndExactDecimalBoundaries(String score, String expected) throws Exception { + var result = assertInstanceOf(DecisionResult.Selected.class, engine.evaluate(scoring(), score(number(score)))); + assertEquals(expected, result.output()); + assertEquals(expected.equals("MANUAL"), result.usedDefault()); + } + + @ParameterizedTest + @ValueSource(strings = {"missing-score.json", "string-score.json"}) + void a05ProviderSchemaRejectsBadScoreAndEvaluatorNeverTreatsItAsLow(String fixture) throws Exception { + var json = new ObjectMapper().readTree(Path.of("examples/contracts/invalid", fixture).toFile()); + var schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(SchemaLocation.of( + Path.of("schemas/integration-results.schema.json").toAbsolutePath().toUri() + "#/$defs/BureauResult")); + assertFalse(schema.validate(json).isEmpty(), "Invalid provider result must fail its contract"); + var data = json.has("score") ? new ObjectValue(Map.of("score", new StringValue(json.path("score").asText()))) : EMPTY; + failure(engine.evaluate(scoring(), context(new ObjectValue(Map.of("buro", data)))), ErrorCode.EVALUATION_ERROR); + } + + @Test + void nullScoreAlsoFailsInsteadOfSelectingDefault() throws Exception { + failure(engine.evaluate(scoring(), score(NullValue.INSTANCE)), ErrorCode.EVALUATION_ERROR); + } + + static Stream comparisons() { + var n1 = number("1.00"); var n2 = number("2"); var one = number("1"); + return Stream.of( + Arguments.of(Condition.Comparison.EQ, n1, one, true), + Arguments.of(Condition.Comparison.NEQ, n1, n2, true), + Arguments.of(Condition.Comparison.GT, n2, n1, true), + Arguments.of(Condition.Comparison.GT, n1, one, false), + Arguments.of(Condition.Comparison.GTE, n1, one, true), + Arguments.of(Condition.Comparison.LT, n1, n2, true), + Arguments.of(Condition.Comparison.LTE, n1, one, true), + Arguments.of(Condition.Comparison.EQ, new StringValue("A"), new StringValue("A"), true), + Arguments.of(Condition.Comparison.NEQ, new BooleanValue(true), new BooleanValue(false), true), + Arguments.of(Condition.Comparison.EQ, NullValue.INSTANCE, NullValue.INSTANCE, true), + Arguments.of(Condition.Comparison.EQ, NullValue.INSTANCE, one, false), + Arguments.of(Condition.Comparison.NEQ, one, NullValue.INSTANCE, true), + Arguments.of(Condition.Comparison.IN, one, new ArrayValue(List.of(n1, n2)), true), + Arguments.of(Condition.Comparison.IN, n2, new ArrayValue(List.of(n1)), false), + Arguments.of(Condition.Comparison.IN, n2, new ArrayValue(List.of()), false), + Arguments.of(Condition.Comparison.IN, new StringValue("A"), new ArrayValue(List.of(new StringValue("A"))), true), + Arguments.of(Condition.Comparison.IN, new BooleanValue(false), new ArrayValue(List.of(new BooleanValue(true))), false), + Arguments.of(Condition.Comparison.EQ, new ObjectValue(Map.of("n", n1)), new ObjectValue(Map.of("n", one)), true), + Arguments.of(Condition.Comparison.EQ, new ArrayValue(List.of(n1)), new ArrayValue(List.of(one)), true), + Arguments.of(Condition.Comparison.EQ, new ArrayValue(List.of(one)), new ArrayValue(List.of()), false)); + } + @ParameterizedTest @MethodSource("comparisons") + void operatorsUseStrictJsonTypesAndDecimalComparison(Condition.Comparison op, JsonValue left, JsonValue right, boolean expected) { + assertEquals(expected, conditions.evaluate(compare(op, left, right), context(EMPTY))); + } + + static Stream invalidComparisons() { + return Stream.of( + Arguments.of(Condition.Comparison.EQ, number("1"), new StringValue("1")), + Arguments.of(Condition.Comparison.NEQ, new BooleanValue(true), number("1")), + Arguments.of(Condition.Comparison.GT, new StringValue("2026-10-26"), new StringValue("2026-10-25")), + Arguments.of(Condition.Comparison.LTE, NullValue.INSTANCE, number("1")), + Arguments.of(Condition.Comparison.IN, number("1"), number("1")), + Arguments.of(Condition.Comparison.IN, number("1"), new ArrayValue(List.of(number("1"), new StringValue("1")))), + Arguments.of(Condition.Comparison.IN, number("1"), new ArrayValue(List.of(number("1"), NullValue.INSTANCE))), + Arguments.of(Condition.Comparison.IN, new StringValue("1"), new ArrayValue(List.of(number("1")))), + Arguments.of(Condition.Comparison.IN, NullValue.INSTANCE, new ArrayValue(List.of(NullValue.INSTANCE))), + Arguments.of(Condition.Comparison.GTE, EMPTY, EMPTY), + Arguments.of(Condition.Comparison.EQ, new ArrayValue(List.of(number("1"))), new ArrayValue(List.of(new StringValue("1"))))); + } + @ParameterizedTest @MethodSource("invalidComparisons") + void incompatibleTypesNeverBecomeFalseOrDefault(Condition.Comparison op, JsonValue left, JsonValue right) { + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, compare(op, left, right)), context(EMPTY)), ErrorCode.EVALUATION_ERROR); + } + + @ParameterizedTest @EnumSource(Condition.Comparison.class) + void everyComparisonRejectsMissingPaths(Condition.Comparison op) { + var condition = new Condition.Compare(op, path("/results/missing"), literal(NullValue.INSTANCE)); + evaluationError(() -> conditions.evaluate(condition, context(EMPTY))); + } + + @Test + void existsDistinguishesPresentNullFromMissingAndLogicalOperatorsShortCircuitInOrder() { + var ctx = context(new ObjectValue(Map.of("null", NullValue.INSTANCE))); + assertTrue(conditions.evaluate(new Condition.Exists(path("/results/null")), ctx)); + assertFalse(conditions.evaluate(new Condition.Exists(path("/results/missing")), ctx)); + assertFalse(conditions.evaluate(new Condition.Logical(Condition.Junction.AND, List.of(truth(false), missing())), ctx)); + assertTrue(conditions.evaluate(new Condition.Logical(Condition.Junction.OR, List.of(truth(true), missing())), ctx)); + assertTrue(conditions.evaluate(new Condition.Not(truth(false)), ctx)); + evaluationError(() -> conditions.evaluate(new Condition.Logical(Condition.Junction.AND, List.of(missing(), truth(false))), ctx)); + evaluationError(() -> conditions.evaluate(new Condition.Logical(Condition.Junction.OR, List.of(missing(), truth(true))), ctx)); + evaluationError(() -> conditions.evaluate(new Condition.Not(missing()), ctx)); + } + + @Test + void uniqueRejectsMultipleMatchesEvenWithIdenticalOutputAndFirstPreservesDeclaredOrder() { + var rules = List.of(new DecisionDefinition.Rule("first", truth(true), "SAME"), + new DecisionDefinition.Rule("second", truth(true), "SAME")); + failure(engine.evaluate(new DecisionDefinition("d", DecisionDefinition.HitPolicy.UNIQUE, rules, "DEFAULT"), + context(EMPTY)), ErrorCode.AMBIGUOUS_DECISION); + assertEquals(new DecisionResult.Selected("SAME", List.of("first")), engine.evaluate( + new DecisionDefinition("d", DecisionDefinition.HitPolicy.FIRST, rules, "DEFAULT"), context(EMPTY))); + assertEquals(new DecisionResult.Selected("OUT0", List.of("rule0")), engine.evaluate( + decision(DecisionDefinition.HitPolicy.FIRST, truth(true), missing()), context(EMPTY))); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, truth(true), missing()), context(EMPTY)), ErrorCode.EVALUATION_ERROR); + assertEquals(new DecisionResult.Selected("DEFAULT", List.of()), engine.evaluate( + decision(DecisionDefinition.HitPolicy.UNIQUE, truth(false)), context(EMPTY))); + } + + @Test + void duplicateRuleIdsAreRejectedBeforeEvaluation() { + var rule = new DecisionDefinition.Rule("same", truth(false), "NO"); + failure(engine.evaluate(new DecisionDefinition("d", DecisionDefinition.HitPolicy.FIRST, List.of(rule, rule), "DEFAULT"), + context(EMPTY)), ErrorCode.EVALUATION_ERROR); + } + + @Test + void limitsApplyToAllRulesAndUnvisitedBranchesWithoutRecursivePreflight() { + var ctx = context(EMPTY); + assertInstanceOf(DecisionResult.Selected.class, new PureDecisionEvaluator(new EvaluationLimits(2, 3)) + .evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, truth(true)), ctx)); + failure(new PureDecisionEvaluator(new EvaluationLimits(2, 2)).evaluate( + decision(DecisionDefinition.HitPolicy.UNIQUE, truth(true)), ctx), ErrorCode.EVALUATION_ERROR); + failure(new PureDecisionEvaluator(new EvaluationLimits(2, 5)).evaluate( + decision(DecisionDefinition.HitPolicy.FIRST, truth(true), truth(false)), ctx), ErrorCode.EVALUATION_ERROR); + Condition deep = truth(false); + for (int i = 0; i < 50_000; i++) deep = new Condition.Not(deep); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.FIRST, truth(true), deep), ctx), ErrorCode.EVALUATION_ERROR); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, + new Condition.Logical(Condition.Junction.OR, List.of(truth(true), deep))), ctx), ErrorCode.EVALUATION_ERROR); + Condition boundary = truth(true); + for (int i = 0; i < 18; i++) boundary = new Condition.Not(boundary); + assertInstanceOf(DecisionResult.Selected.class, engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, boundary), ctx)); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, new Condition.Not(boundary)), ctx), ErrorCode.EVALUATION_ERROR); + } + + @Test + void dataComparisonsAreBoundedAndLimitsCannotBeRaisedArbitrarily() { + var big = new ArrayValue(Collections.nCopies(1001, number("1"))); + evaluationError(() -> conditions.evaluate(compare(Condition.Comparison.IN, number("1"), big), context(EMPTY))); + evaluationError(() -> conditions.evaluate(compare(Condition.Comparison.EQ, big, big), context(EMPTY))); + assertThrows(IllegalArgumentException.class, () -> new EvaluationLimits(21, 1000)); + assertThrows(IllegalArgumentException.class, () -> new EvaluationLimits(20, 1001)); + assertThrows(IllegalArgumentException.class, () -> new EvaluationLimits(0, 100)); + } + + @Test + void defaultNodeBudgetCountsEveryOccurrenceAcrossAllRules() { + // OR + 333 * (comparison + left + right) = exactly 1000 AST nodes. + var exact = new Condition.Logical(Condition.Junction.OR, Collections.nCopies(333, truth(false))); + assertEquals(new DecisionResult.Selected("DEFAULT", List.of()), engine.evaluate( + decision(DecisionDefinition.HitPolicy.UNIQUE, exact), context(EMPTY))); + var over = new Condition.Logical(Condition.Junction.OR, Collections.nCopies(334, truth(false))); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.UNIQUE, over), context(EMPTY)), ErrorCode.EVALUATION_ERROR); + failure(engine.evaluate(decision(DecisionDefinition.HitPolicy.FIRST, truth(true), exact), context(EMPTY)), ErrorCode.EVALUATION_ERROR); + var manyRules = new ArrayList(); + for (int i = 0; i < 1001; i++) manyRules.add(new DecisionDefinition.Rule("r" + i, truth(false), "NO")); + failure(engine.evaluate(new DecisionDefinition("d", DecisionDefinition.HitPolicy.FIRST, manyRules, "DEFAULT"), + context(EMPTY)), ErrorCode.EVALUATION_ERROR); + } + + @Test + void evaluatorIsReusableConcurrentlyAndCannotMutateContext() throws Exception { + var ctx = score(number("71")); var snapshot = ctx.results(); var definition = scoring(); + try (var executor = Executors.newFixedThreadPool(4)) { + var jobs = new ArrayList>(); + for (int i = 0; i < 40; i++) jobs.add(() -> engine.evaluate(definition, ctx)); + for (var result : executor.invokeAll(jobs)) + assertEquals("N", assertInstanceOf(DecisionResult.Selected.class, result.get()).output()); + } + assertSame(snapshot, ctx.results()); + assertEquals(number("71"), ((ObjectValue) snapshot.fields().get("buro")).fields().get("score")); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DemoFlowTest.java b/src/test/java/com/organizacion/orchestrator/DemoFlowTest.java new file mode 100644 index 0000000..bd40a65 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DemoFlowTest.java @@ -0,0 +1,16 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.flow.infrastructure.ConfiguredFlowValidator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class DemoFlowTest { + @ParameterizedTest @ValueSource(strings={"credito-v1", "credito-v2-licencia", "compra-v1"}) + void exactExampleIsPublishable(String name)throws Exception { + var flow=FlowFixtures.CODEC.decode(Files.readString(Path.of("examples/flows/"+name+".json"))); + var report=new ConfiguredFlowValidator(HttpProviderFixture.exampleCatalog(),FlowFixtures.CODEC).validate(flow); + assertTrue(report.valid(),report.toString()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DemoFlowsIT.java b/src/test/java/com/organizacion/orchestrator/DemoFlowsIT.java new file mode 100644 index 0000000..b62cdad --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DemoFlowsIT.java @@ -0,0 +1,134 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.integration.infrastructure.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; +import static com.organizacion.orchestrator.AvailableEventsIT.available; + +/** Exact shipped JSON through real API, SQL, worker and loopback providers. */ +class DemoFlowsIT { + static UUID v1,v2,compra; + HttpProviderFixture clients,metadata,validation,bureau; + RegisteredWorkerActions adapters; + @BeforeAll static void setup()throws Exception { + SecurityHttpIT.start(true,true,"--orchestrator.flow.catalog-path="+Path.of("examples/integrations/publication-catalog.json").toAbsolutePath()); + WorkerIT.store=fresh(); + admin.update("INSERT INTO "+schema+".request_type(type_key) VALUES ('CREDITO'),('COMPRA')"); + v1=publishExample("credito-v1");v2=publishExample("credito-v2-licencia");compra=publishExample("compra-v1"); + } + @AfterAll static void stopAll(){SecurityHttpIT.stop();} + static UUID publishExample(String name)throws Exception { + var created=call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",""),Files.readString(Path.of("examples/flows/"+name+".json")),null); + assertEquals(201,created.statusCode(),created.body()); + var id=UUID.fromString(json.readTree(created.body()).get("id").asText());String path="/api/v1/admin/flows/"+id; + var checked=call("POST",path+"/validate",token("FLOW_EDITOR","scope-a",""),null,null); + assertEquals(200,checked.statusCode(),checked.body()); + var published=call("POST",path+"/publish",token("FLOW_PUBLISHER","scope-a",""),null,"\"0\""); + assertEquals(200,published.statusCode(),published.body());return id; + } + static void activate(String type,UUID flow)throws Exception { + long revision=admin.queryForObject("SELECT revision FROM "+schema+".request_type WHERE type_key=?",Long.class,type); + var r=call("PUT","/api/v1/admin/request-types/"+type+"/active-flow",token("FLOW_ADMIN","scope-a",""),"{\"flowId\":\""+flow+"\"}","\""+revision+"\""); + assertEquals(200,r.statusCode(),r.body()); + } + @BeforeEach void before()throws Exception { + new WorkerIT().isolateQueue();activate("CREDITO",v1);activate("COMPRA",compra); + clients=new HttpProviderFixture();metadata=new HttpProviderFixture();validation=new HttpProviderFixture();bureau=new HttpProviderFixture(); + clients.body="{\"found\":true,\"customerId\":\"synthetic-customer\"}"; + validation.body="{\"status\":\"VALID\",\"reasonCodes\":[]}"; + bureau.body="{\"score\":71,\"consultationId\":\"synthetic-consultation\"}"; + var catalog=HttpProviderFixture.exampleCatalog();var routes=new ArrayList(); + routes.add(clients.route("CLIENTES_CONSULTA","",2000,8000,Map.of(),Optional.empty(),"")); + routes.add(metadata.route("DOCUMENTS_METADATA","",2000,8000,Map.of(),Optional.empty(),"")); + routes.add(bureau.route("BURO_CONSULTA","",2000,8000,Map.of(),Optional.empty(),"")); + for(String profile:List.of("INE","ESTADO_CUENTA","LICENCIA_CONDUCIR"))routes.add(validation.route("DOCUMENTO_VALIDA",profile,2000,8000,Map.of(),Optional.empty(),"")); + var http=new HttpIntegrationHandler(catalog,new HttpRoutes(routes.stream().map(r->new HttpRoutes.Route(r.key(),r.uri(),r.tokenEnvironment(),1000,5000,r.maxBytes(),r.outputMappings(),r.notFoundOutput())).toList(),catalog,true,60),metadata.http,FlowFixtures.CODEC,name->null); + adapters=new RegisteredWorkerActions(http,new RegisterDocumentHandler(catalog,http)); + } + @AfterEach void after(){for(var provider:List.of(clients,metadata,validation,bureau))provider.close();} + static UUID create(String type)throws Exception { + String data=type.equals("CREDITO")?"{\"client\":\"synthetic-client\",\"consentimientoId\":\"synthetic-consent\"}":"{\"client\":\"synthetic-client\",\"orderId\":\"synthetic-order\",\"amount\":100}"; + var r=RequestCreationIT.post(type,unique(),data,RequestCreationIT.jwt());assertEquals(201,r.statusCode(),r.body()); + return UUID.fromString(json.readTree(r.body()).get("id").asText()); + } + UUID run(UUID request,String event,String data)throws Exception { + var tasks=available(request);assertEquals(event,tasks.at("/events/0/event").asText(),tasks.toString()); + var r=EventAcceptanceIT.event(request,unique(),"\""+EventAcceptanceIT.revision(request)+"\"","{\"event\":\""+event+"\",\"data\":"+data+"}",RequestCreationIT.jwt()); + assertEquals(202,r.statusCode(),r.body());UUID execution=UUID.fromString(json.readTree(r.body()).get("executionId").asText()); + assertTrue(available(request).get("events").isEmpty());engine(adapters).run(claim("demo"));return execution; + } + void success(UUID request,String event,String data)throws Exception{UUID id=run(request,event,data);assertEquals("SUCCEEDED",status(id),()->admin.queryForMap("SELECT * FROM "+schema+".execution WHERE id=?",id).toString());} + void document(UUID request,String type)throws Exception { + UUID id=UUID.randomUUID();metadata.body=DocumentTasksIT.metadata(request,id).put("documentType",type).put("storageDocumentId",id.toString()).toString(); + success(request,"REGISTRAR_"+(type.equals("LICENCIA_CONDUCIR")?"LICENCIA":type),"{\"documentId\":\""+id+"\"}"); + assertEquals("1",validation.profile); + } + void prepare(UUID request)throws Exception {success(request,"ASIGNAR_CLIENTE","{}");document(request,"INE");document(request,"ESTADO_CUENTA");} + static String state(UUID request)throws Exception { + var r=call("GET","/api/v1/requests/"+request,RequestCreationIT.jwt(),null,null);assertEquals(200,r.statusCode(),r.body()); + return json.readTree(r.body()).get("currentState").asText(); + } + @ParameterizedTest @ValueSource(ints={71,70,61,60}) void creditBoundaries(int score)throws Exception { + UUID request=create("CREDITO");prepare(request);bureau.body="{\"score\":"+score+",\"consultationId\":\"synthetic-consultation\"}"; + success(request,"VALIDAR_BURO","{}");assertEquals(score>70?"PASO_N":score>60?"PASO_M":"REVISION_MANUAL",state(request)); + assertTrue(available(request).get("events").isEmpty()); + assertEquals("synthetic-customer",json.readTree(bureau.receivedBody).get("client").asText()); + assertEquals("synthetic-consent",json.readTree(bureau.receivedBody).get("consentimientoId").asText()); + } + @Test void existingCreditKeepsV1WhileNewCreditRequiresLicense()throws Exception { + UUID old=create("CREDITO");activate("CREDITO",v2);UUID newer=create("CREDITO");prepare(old);prepare(newer); + assertEquals("BURO_PENDIENTE",state(old));assertEquals("LICENCIA_PENDIENTE",state(newer)); + assertEquals(v1,admin.queryForObject("SELECT flow_id FROM "+schema+".request_instance WHERE id=?",UUID.class,old)); + assertEquals(v2,admin.queryForObject("SELECT flow_id FROM "+schema+".request_instance WHERE id=?",UUID.class,newer)); + var premature=EventAcceptanceIT.event(newer,unique(),"\""+EventAcceptanceIT.revision(newer)+"\"","{\"event\":\"VALIDAR_BURO\",\"data\":{}}",RequestCreationIT.jwt()); + assertEquals(409,premature.statusCode()); + validation.body="{\"status\":\"INVALID\",\"reasonCodes\":[\"EXPIRED\"]}";document(newer,"LICENCIA_CONDUCIR");assertEquals("LICENCIA_PENDIENTE",state(newer)); + var details=call("GET","/api/v1/requests/"+newer,RequestCreationIT.jwt(),null,null); + assertEquals("EXPIRED",json.readTree(details.body()).at("/data/validaciones/licencia/reasonCodes/0").asText()); + validation.body="{\"status\":\"VALID\",\"reasonCodes\":[]}";document(newer,"LICENCIA_CONDUCIR"); + success(old,"VALIDAR_BURO","{}");success(newer,"VALIDAR_BURO","{}");assertEquals("PASO_N",state(old));assertEquals("PASO_N",state(newer)); + } + @ParameterizedTest @ValueSource(booleans={true,false}) void purchaseCompletesWithoutCreditActions(boolean confirm)throws Exception { + UUID request=create("COMPRA");success(request,"ASIGNAR_CLIENTE","{}");success(request,"CONFIRMAR_COMPRA","{\"confirmada\":"+confirm+"}"); + assertEquals(confirm?"COMPRA_CONFIRMADA":"COMPRA_CANCELADA",state(request));assertTrue(available(request).get("events").isEmpty()); + assertEquals(0,bureau.calls.get());assertEquals(0,metadata.calls.get());assertEquals(0,validation.calls.get()); + } + @Test void missingCustomerRemainsCreatedAndCanBeRetried()throws Exception { + UUID request=create("CREDITO");clients.body="{\"found\":false}";UUID missing=run(request,"ASIGNAR_CLIENTE","{}");assertEquals("SUCCEEDED",status(missing));assertEquals("CREADA",state(request)); + assertEquals("CLIENTE_NO_ENCONTRADO",admin.queryForObject("SELECT result->>'outcome' FROM "+schema+".execution WHERE id=?",String.class,missing)); + clients.body="{\"found\":true,\"customerId\":\"synthetic-customer\"}";prepare(request);success(request,"VALIDAR_BURO","{}");assertEquals("PASO_N",state(request)); + } + @Test void customerApiFailureRetriesAndNeverBecomesCustomerNotFound()throws Exception { + UUID request=create("CREDITO");clients.status=503;clients.body="{}"; + UUID execution=run(request,"ASIGNAR_CLIENTE","{}");assertEquals("RETRY_WAIT",status(execution)); + for(int attempt=2;attempt<=3;attempt++){ + admin.update("UPDATE "+schema+".execution SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE id=?",execution); + engine(adapters).run(claim("client-retry"));assertEquals(attempt<3?"RETRY_WAIT":"FAILED",status(execution)); + } + assertEquals(3,clients.calls.get());assertEquals("CREADA",state(request));assertEquals(0,metadata.calls.get()); + assertNull(admin.queryForObject("SELECT result FROM "+schema+".execution WHERE id=?",String.class,execution)); + assertNull(admin.queryForObject("SELECT data->'cliente' FROM "+schema+".request_instance WHERE id=?",String.class,request)); + } + @Test void repeatedEventHasOneExecutionAndOneProviderCallEvenAfterCompletion()throws Exception { + UUID request=create("CREDITO");String key=unique(),body="{\"event\":\"ASIGNAR_CLIENTE\",\"data\":{}}"; + var first=EventAcceptanceIT.event(request,key,"\"0\"",body,RequestCreationIT.jwt());assertEquals(202,first.statusCode()); + var again=EventAcceptanceIT.event(request,key,"\"0\"",body,RequestCreationIT.jwt());assertEquals(202,again.statusCode());assertEquals(first.body(),again.body()); + engine(adapters).run(claim("single-effect"));assertEquals("INE_PENDIENTE",state(request)); + var completed=EventAcceptanceIT.event(request,key,"\"0\"",body,RequestCreationIT.jwt());assertEquals(202,completed.statusCode()); + assertEquals(json.readTree(first.body()).get("executionId"),json.readTree(completed.body()).get("executionId")); + assertEquals(1,clients.calls.get());assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".execution WHERE request_id=?",Integer.class,request)); + assertTrue(WorkerIT.store.claim("no-repeat",1,java.time.Duration.ofSeconds(60)).isEmpty()); + } + @ParameterizedTest @ValueSource(strings={"{\"consultationId\":\"synthetic\"}","{\"score\":\"60\",\"consultationId\":\"synthetic\"}"}) + void invalidProviderScoreNeverDefaultsToManualReview(String response)throws Exception { + UUID request=create("CREDITO");prepare(request);bureau.body=response; + assertEquals("FAILED",status(run(request,"VALIDAR_BURO","{}")));assertEquals("BURO_PENDIENTE",state(request)); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DocumentTasksIT.java b/src/test/java/com/organizacion/orchestrator/DocumentTasksIT.java new file mode 100644 index 0000000..4e27bb0 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DocumentTasksIT.java @@ -0,0 +1,138 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.flow.application.FlowAdministration; +import com.organizacion.orchestrator.flow.infrastructure.*; +import com.organizacion.orchestrator.integration.infrastructure.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; +import static com.organizacion.orchestrator.HttpProviderFixture.*; +import static com.organizacion.orchestrator.AvailableEventsIT.available; + +class DocumentTasksIT { + @BeforeAll static void setup()throws Exception{ + RequestCreationIT.setup();WorkerIT.store=fresh(); + RequestCreationIT.flows=new FlowAdministration(RequestCreationIT.flowStore,new CatalogFlowPreparation(FlowFixtures.CODEC,new ConfiguredFlowValidator(exampleCatalog(),FlowFixtures.CODEC))); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @BeforeEach void isolate(){new WorkerIT().isolateQueue();} + static ObjectNode documentFlow(){ + var n=FlowFixtures.simple();n.put("flowKey",unique());n.set("initialDataSchema",FlowFixtures.node("{\"type\":\"object\",\"additionalProperties\":false}")); + var properties=FlowFixtures.JSON.createObjectNode();properties.set("registeredDocumentId",FlowFixtures.node("{\"type\":\"string\",\"format\":\"uuid\"}")); + properties.set("validation",FlowFixtures.CODEC.tree(exampleCatalog().integrations().get("DOCUMENTO_VALIDA").contracts().get("1").output().value())); + ((ObjectNode)n.get("dataSchema")).set("properties",properties); + var input=FlowFixtures.node("{\"type\":\"object\",\"properties\":{\"documentId\":{\"type\":\"string\",\"format\":\"uuid\"}},\"required\":[\"documentId\"],\"additionalProperties\":false}"); + ((ObjectNode)n.at("/events/0")).set("inputSchema",input); + var t=(ObjectNode)n.at("/transitions/0");t.remove("to");t.remove("outcome");t.put("decision","DOCUMENT_STATUS"); + t.set("actions",FlowFixtures.node(""" + [{"id":"register","type":"REGISTER_DOCUMENT","documentType":"LICENCIA_CONDUCIR","inputs":{"documentId":{"path":"/event/data/documentId"}},"resultKey":"document"}, + {"id":"validate","type":"INVOKE_INTEGRATION","integrationKey":"DOCUMENTO_VALIDA","profile":"LICENCIA_CONDUCIR","inputs":{"documentId":{"path":"/results/document/documentId"}},"resultKey":"validation"}] + """)); + t.set("updates",FlowFixtures.node(""" + [{"target":"/registeredDocumentId","value":{"path":"/results/document/documentId"}},{"target":"/validation","value":{"path":"/results/validation"}}] + """)); + t.set("destinations",FlowFixtures.node("{\"DOCUMENTO_VALIDO\":\"END\",\"DOCUMENTO_INVALIDO\":\"START\"}")); + n.set("decisions",FlowFixtures.node(""" + [{"id":"DOCUMENT_STATUS","hitPolicy":"UNIQUE","rules":[{"id":"valid","when":{"op":"EQ","left":{"path":"/results/validation/status"},"right":{"literal":"VALID"}},"output":"DOCUMENTO_VALIDO"}],"defaultOutput":"DOCUMENTO_INVALIDO"}] + """)); + var task=n.withArray("tasks").addObject().put("state","START").put("event","SUBMIT");task.set("inputSchema",input); + task.putObject("ui").put("component","DOCUMENT_UPLOAD").put("title","Licencia").put("documentType","LICENCIA_CONDUCIR").put("maxBytes",1000).putArray("acceptedMimeTypes").add("application/pdf"); + return n; + } + static UUID create()throws Exception{return AvailableEventsIT.create(publish(documentFlow()),"{}");} + static UUID submit(UUID request,UUID document,long revision)throws Exception{ + var r=EventAcceptanceIT.event(request,unique(),"\""+revision+"\"","{\"event\":\"SUBMIT\",\"data\":{\"documentId\":\""+document+"\"}}",RequestCreationIT.jwt()); + assertEquals(202,r.statusCode(),r.body());return UUID.fromString(json.readTree(r.body()).get("executionId").asText()); + } + static ObjectNode metadata(UUID request,UUID document){ + var c=context();var identity=new ActionContext(c.executionId(),new com.organizacion.orchestrator.shared.domain.Identifiers.RequestId(request),RequestCreationIT.owner,object("{}"),"metadata",1); + return HttpProviderFixture.metadata(identity,document).put("documentType","LICENCIA_CONDUCIR"); + } + static RegisteredWorkerActions adapters(HttpProviderFixture metadata,HttpProviderFixture validation){ + var catalog=exampleCatalog();var routes=new HttpRoutes(List.of(metadata.route("DOCUMENTS_METADATA","",2000,2000,Map.of(),Optional.empty(),""), + validation.route("DOCUMENTO_VALIDA","LICENCIA_CONDUCIR",2000,2000,Map.of(),Optional.empty(),"")),catalog,true,60); + var http=new HttpIntegrationHandler(catalog,routes,metadata.http,FlowFixtures.CODEC,name->null); + return new RegisteredWorkerActions(http,new RegisterDocumentHandler(catalog,http)); + } + @Test void invalidRetainsTaskAndReasonThenValidCompletesWithoutDuplicateReference()throws Exception{ + try(var metadata=new HttpProviderFixture();var validation=new HttpProviderFixture()){ + UUID request=create(),document=UUID.randomUUID();var initial=available(request); + assertEquals("DOCUMENT_UPLOAD",initial.at("/events/0/ui/component").asText());assertEquals(1000,initial.at("/events/0/ui/maxBytes").asLong()); + assertEquals("uuid",initial.at("/events/0/inputSchema/properties/documentId/format").asText()); + metadata.body=metadata(request,document).toString();validation.body="{\"status\":\"INVALID\",\"reasonCodes\":[\"EXPIRED\"]}"; + var actions=adapters(metadata,validation);UUID first=submit(request,document,0);assertTrue(available(request).get("events").isEmpty()); + engine(actions).run(claim("invalid"));assertEquals("SUCCEEDED",status(first));assertEquals(1,available(request).get("events").size()); + assertEquals("DOCUMENTO_INVALIDO",admin.queryForObject("SELECT result->>'outcome' FROM "+schema+".execution WHERE id=?",String.class,first)); + assertEquals("EXPIRED",admin.queryForObject("SELECT detail->'reasonCodes'->>0 FROM "+schema+".audit_entry WHERE execution_id=? AND kind='TRANSITION_COMPLETED'",String.class,first)); + var details=call("GET","/api/v1/requests/"+request,RequestCreationIT.jwt(),null,null); + assertEquals("INVALID",json.readTree(details.body()).at("/data/validation/status").asText());assertEquals("EXPIRED",json.readTree(details.body()).at("/data/validation/reasonCodes/0").asText()); + validation.body="{\"status\":\"VALID\",\"reasonCodes\":[]}";UUID second=submit(request,document,2);engine(actions).run(claim("valid")); + assertEquals("SUCCEEDED",status(second));assertTrue(available(request).get("events").isEmpty());assertFalse(available(request).has("blockedByExecutionId")); + assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".document_reference WHERE request_id=?",Integer.class,request)); + assertEquals(2,metadata.calls.get());assertEquals(2,validation.calls.get()); + } + } + @ParameterizedTest @ValueSource(strings={"requestId","businessScope","documentId","documentType","mimeType","sizeBytes","checksum","scanStatus","pendingScan"}) + void untrustedOrNoncompliantMetadataNeverCreatesReference(String field)throws Exception{ + try(var metadata=new HttpProviderFixture();var validation=new HttpProviderFixture()){ + UUID request=create(),document=UUID.randomUUID();var body=metadata(request,document); + switch(field){ + case "requestId","documentId"->body.put(field,UUID.randomUUID().toString()); + case "sizeBytes"->body.put(field,1001); // below catalog maximum, above published task maximum + case "mimeType"->body.put(field,"image/png"); // catalog allows it, this task does not + case "scanStatus"->body.put(field,"INFECTED"); + case "pendingScan"->body.put("scanStatus","PENDING"); + default->body.put(field,"wrong"); + } + metadata.body=body.toString();validation.body="{\"status\":\"VALID\",\"reasonCodes\":[]}"; + UUID id=submit(request,document,0);engine(adapters(metadata,validation)).run(claim("metadata")); + assertEquals("FAILED",status(id));assertEquals(0,validation.calls.get()); + assertEquals(0,admin.queryForObject("SELECT count(*) FROM "+schema+".document_reference WHERE request_id=?",Integer.class,request)); + assertEquals(1,available(request).get("events").size());assertEquals("START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,request)); + } + } + @Test void technicalValidationFailureIsRetriedAndNeverBecomesInvalid()throws Exception{ + try(var metadata=new HttpProviderFixture();var validation=new HttpProviderFixture()){ + UUID request=create(),document=UUID.randomUUID();metadata.body=metadata(request,document).toString();validation.status=503;validation.body="{}"; + UUID id=submit(request,document,0);var actions=adapters(metadata,validation); + for(int attempt=1;attempt<=3;attempt++){ + engine(actions).run(claim("technical-"+attempt)); + if(attempt<3){assertEquals("RETRY_WAIT",status(id));assertEquals(id.toString(),available(request).get("blockedByExecutionId").asText());admin.update("UPDATE "+schema+".execution SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE id=?",id);} + } + assertEquals("FAILED",status(id));assertEquals(1,metadata.calls.get());assertEquals(3,validation.calls.get());assertEquals(1,available(request).get("events").size()); + assertNull(admin.queryForObject("SELECT data->'validation' FROM "+schema+".request_instance WHERE id=?",String.class,request)); + assertNull(admin.queryForObject("SELECT result FROM "+schema+".execution WHERE id=?",String.class,id)); + } + } + @Test void anExistingReferenceCannotBeReassignedEvenIfRemoteMetadataDisagrees()throws Exception{ + try(var metadata=new HttpProviderFixture();var validation=new HttpProviderFixture()){ + UUID firstRequest=create(),secondRequest=create(),document=UUID.randomUUID(); + var actions=adapters(metadata,validation);validation.body="{\"status\":\"VALID\",\"reasonCodes\":[]}"; + metadata.body=metadata(firstRequest,document).toString();UUID first=submit(firstRequest,document,0);engine(actions).run(claim("first-owner")); + assertEquals("SUCCEEDED",status(first)); + metadata.body=metadata(secondRequest,document).toString();UUID second=submit(secondRequest,document,0);engine(actions).run(claim("second-owner")); + assertEquals("FAILED",status(second));assertEquals(1,validation.calls.get()); + assertEquals(firstRequest,admin.queryForObject("SELECT request_id FROM "+schema+".document_reference WHERE id=?",UUID.class,document)); + assertEquals(0,admin.queryForObject("SELECT count(*) FROM "+schema+".document_reference WHERE request_id=?",Integer.class,secondRequest)); + assertEquals("FAILED",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=? AND action_id='register'",String.class,second)); + } + } + @Test void clientCannotSubmitProviderFieldsOrReplaceMetadata()throws Exception{ + var f=publish(documentFlow());String type=RequestCreationIT.type(f); + var created=RequestCreationIT.post(type,unique(),"{\"validation\":{\"status\":\"VALID\",\"reasonCodes\":[]}}",RequestCreationIT.jwt());assertEquals(422,created.statusCode()); + UUID request=create(),document=UUID.randomUUID(); + for(String extra:List.of("\"score\":100","\"validation\":{\"status\":\"VALID\"}","\"mimeType\":\"application/pdf\"","\"scanStatus\":\"CLEAN\"","\"ownerSubject\":\"someone\"")){ + var r=EventAcceptanceIT.event(request,unique(),"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"documentId\":\""+document+"\","+extra+"}}",RequestCreationIT.jwt()); + assertEquals(422,r.statusCode(),r.body()); + } + assertEquals(0,admin.queryForObject("SELECT count(*) FROM "+schema+".execution WHERE request_id=?",Integer.class,request)); + assertEquals(0L,EventAcceptanceIT.revision(request));assertEquals(1,available(request).get("events").size()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DomainArchitectureTest.java b/src/test/java/com/organizacion/orchestrator/DomainArchitectureTest.java new file mode 100644 index 0000000..c5493e7 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DomainArchitectureTest.java @@ -0,0 +1,45 @@ +package com.organizacion.orchestrator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.ToolProvider; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DomainArchitectureTest { + @TempDir Path isolated; + + @Test + void allDomainAndPortSourcesCompileWithOnlyJavaBaseAndWithoutApiOrPersistence() throws Exception { + List sources; + try (var paths = Files.walk(Path.of("src/main/java/com/organizacion/orchestrator"))) { + sources = paths.filter(p -> p.toString().endsWith(".java")) + .filter(p -> p.toString().replace('\\', '/').matches(".*/(domain|application/port)/.*") + || p.toString().replace('\\', '/').contains("/decision/application/") + || p.toString().replace('\\', '/').contains("/flow/application/") + || p.toString().replace('\\', '/').contains("/shared/application/") + || p.toString().replace('\\', '/').contains("/request/application/") + || p.toString().replace('\\', '/').contains("/execution/application/")).toList(); + } + assertTrue(sources.size() >= 30, "Do not silently stop checking domain sources"); + for (var source : sources) { + String code = Files.readString(source).replaceAll("(?s)/\\*.*?\\*/", "").replaceAll("//[^\\r\\n]*", ""); + assertFalse(code.matches("(?s).*\\bjava\\.(net|sql)\\..*"), source.toString()); + } + var compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Architecture verification requires JDK 21"); + try (var manager = compiler.getStandardFileManager(null, null, null)) { + var output = new StringWriter(); + var task = compiler.getTask(output, manager, null, + List.of("--release", "21", "--limit-modules", "java.base", + "-classpath", isolated.toString(), "-d", isolated.toString(), "-proc:none"), + null, manager.getJavaFileObjectsFromPaths(sources)); + assertTrue(task.call(), output.toString()); + } + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DomainBoundaryTest.java b/src/test/java/com/organizacion/orchestrator/DomainBoundaryTest.java new file mode 100644 index 0000000..60e4863 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DomainBoundaryTest.java @@ -0,0 +1,32 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SpecVersion; +import com.organizacion.orchestrator.flow.api.ValidationReportResponse; +import com.organizacion.orchestrator.flow.domain.ValidationReport; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DomainBoundaryTest { + @ParameterizedTest + @EnumSource(ValidationReport.Severity.class) + void validationDtoMatchesPublishedSchemaWithoutLeakingDomainSeverity(ValidationReport.Severity severity) { + var issue = new ValidationReport.Issue("/states/0", "UNREACHABLE_STATE", severity, "Unreachable state"); + var report = new ValidationReport(List.of(issue)); + var dto = ValidationReportResponse.from(report); + var json = new ObjectMapper().valueToTree(dto); + var location = Path.of("schemas/api.schema.json").toAbsolutePath().toUri() + "#/$defs/ValidationReport"; + var schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(SchemaLocation.of(location)); + assertTrue(schema.validate(json).isEmpty(), () -> schema.validate(json).toString()); + assertEquals(severity == ValidationReport.Severity.WARNING, dto.valid()); + assertFalse(json.toString().contains("severity")); + assertThrows(UnsupportedOperationException.class, () -> dto.errors().clear()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/DomainModelTest.java b/src/test/java/com/organizacion/orchestrator/DomainModelTest.java new file mode 100644 index 0000000..92c7b61 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/DomainModelTest.java @@ -0,0 +1,240 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.execution.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.request.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class DomainModelTest { + private static final ObjectValue EMPTY = ObjectValue.empty(); + private static final Instant NOW = Instant.parse("2026-10-26T12:00:00Z"); + private static final Actor ACTOR = new Actor("subject", "scope", Set.of("ANALISTA")); + private static final FlowId FLOW = new FlowId(UUID.randomUUID()); + private static final RequestId REQUEST = new RequestId(UUID.randomUUID()); + private static final Condition EXISTS = new Condition.Exists(new ValueExpression.Path("/results/score")); + + private FlowDefinition definition() { + return new FlowDefinition(1, "CUSTOM_FLOW", 2, "CUSTOM_STATE", Optional.empty(), Optional.empty(), + List.of(new FlowDefinition.State("CUSTOM_STATE", true)), List.of(), List.of(), List.of(), List.of()); + } + + @Test + void jsonSnapshotsCannotBeChangedThroughOriginalCollectionsOrAccessors() { + var mutable = new HashMap(); + var items = new ArrayList(List.of(new NumberValue(new BigDecimal("70.00000000000000000001")))); + var array = new ArrayValue(items); + mutable.put("nested", array); + var snapshot = new ObjectValue(mutable); + items.clear(); mutable.clear(); + assertEquals(1, snapshot.fields().size()); + assertEquals(new BigDecimal("70.00000000000000000001"), ((NumberValue) array.values().getFirst()).value()); + assertThrows(UnsupportedOperationException.class, () -> snapshot.fields().clear()); + assertThrows(UnsupportedOperationException.class, () -> array.values().clear()); + } + + @Test + void missingIsDistinctFromExplicitJsonNullAndJavaNullIsRejected() { + assertNotEquals(Lookup.Missing.INSTANCE, new Lookup.Present(NullValue.INSTANCE)); + assertEquals(NullValue.INSTANCE, new ValueExpression.Literal(NullValue.INSTANCE).value()); + assertThrows(NullPointerException.class, () -> new ValueExpression.Literal(null)); + assertThrows(NullPointerException.class, () -> new Lookup.Present(null)); + var invalid = new HashMap(); invalid.put("field", null); + assertThrows(NullPointerException.class, () -> new ObjectValue(invalid)); + } + + @ParameterizedTest + @ValueSource(strings = {"/request/data/a~1b", "/event/data", "/results/a~0b", "/actor/subject", "/results/"}) + void contextPathsPreserveValidJsonPointers(String path) { + assertEquals(path, new ValueExpression.Path(path).pointer()); + } + + @ParameterizedTest + @ValueSource(strings = {"", "/request/id", "/event/roles", "/results/~2", "results/x", "T(java.lang.Runtime)", "/actorx"}) + void contextPathsRejectProtectedRootsAndExpressions(String path) { + assertThrows(IllegalArgumentException.class, () -> new ValueExpression.Path(path)); + } + + @Test + void astKeepsLogicalAndRuleOrderAndRequiresExplicitHitPolicy() { + var other = new Condition.Not(EXISTS); + var args = new ArrayList(List.of(EXISTS, other)); + var logical = new Condition.Logical(Condition.Junction.AND, args); + args.clear(); + assertEquals(List.of(EXISTS, other), logical.args()); + assertThrows(UnsupportedOperationException.class, () -> logical.args().clear()); + assertThrows(IllegalArgumentException.class, () -> new Condition.Logical(Condition.Junction.OR, List.of(EXISTS))); + var rules = new ArrayList<>(List.of(new DecisionDefinition.Rule("first", EXISTS, "YES"), + new DecisionDefinition.Rule("second", other, "NO"))); + var decision = new DecisionDefinition("route", DecisionDefinition.HitPolicy.FIRST, rules, "MANUAL"); + rules.clear(); + assertEquals(List.of("first", "second"), decision.rules().stream().map(DecisionDefinition.Rule::id).toList()); + assertThrows(NullPointerException.class, () -> new DecisionDefinition("route", null, decision.rules(), "MANUAL")); + assertThrows(IllegalArgumentException.class, () -> new DecisionDefinition("route", decision.hitPolicy(), List.of(), "MANUAL")); + } + + @Test + void decisionFailureCannotMasqueradeAsDefaultRoute() { + var selected = new DecisionResult.Selected("MANUAL", List.of()); + assertTrue(selected.usedDefault()); + assertFalse(new DecisionResult.Selected("YES", List.of("rule")).usedDefault()); + assertInstanceOf(DecisionResult.Failure.class, new DecisionResult.Failure(ErrorCode.EVALUATION_ERROR)); + assertThrows(IllegalArgumentException.class, () -> new DecisionResult.Failure(ErrorCode.ACCESS_DENIED)); + } + + @Test + void flowPreservesDynamicBusinessKeysAndOrderedActions() { + var first = new ActionDefinition.NoOp("one", "one", EMPTY); + var second = new ActionDefinition.NoOp("two", "two", EMPTY); + var actions = new ArrayList(List.of(first, second)); + var transition = new Transition("transition", "STATE_NEW", "EVENT_NEW", Set.of("ANALISTA"), + Optional.empty(), actions, new Transition.Routing.Fixed("STATE_OTHER", "DECLINED"), List.of()); + actions.clear(); + assertEquals(List.of(first, second), transition.actions()); + assertEquals("CUSTOM_STATE", definition().initialState()); + assertThrows(UnsupportedOperationException.class, () -> definition().states().clear()); + assertThrows(IllegalArgumentException.class, () -> new Transition.Update("", new ValueExpression.Literal(NullValue.INSTANCE))); + assertEquals("/id", new Transition.Update("/id", new ValueExpression.Literal(NullValue.INSTANCE)).target(), + "This refers to data.id, never the request resource id"); + } + + @Test + void integrationVersionMayBeUnresolvedInDraftButProfileVersionNeedsProfile() { + var draft = new ActionDefinition.Integration("lookup", "result", "CUSTOMERS", Optional.empty(), + Optional.empty(), Optional.empty(), Map.of(), RetryPolicy.DEFAULT); + assertTrue(draft.contractVersion().isEmpty()); + assertThrows(IllegalArgumentException.class, () -> new ActionDefinition.Integration("lookup", "result", + "CUSTOMERS", Optional.empty(), Optional.empty(), Optional.of("1"), Map.of(), RetryPolicy.DEFAULT)); + } + + @Test + void technicalFailuresAndNegativeBusinessOutcomesAreDifferentTypes() { + var notFound = new ActionResult.Completed(new ObjectValue(Map.of("found", new BooleanValue(false))), Optional.empty()); + var invalid = new ActionResult.Completed(new ObjectValue(Map.of("status", new StringValue("INVALID"))), Optional.empty()); + var unknown = new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE, ActionResult.FailureKind.UNKNOWN_EFFECT); + assertInstanceOf(ActionResult.Completed.class, notFound); + assertInstanceOf(ActionResult.Completed.class, invalid); + assertInstanceOf(ActionResult.TechnicalFailure.class, unknown); + assertEquals(ActionResult.FailureKind.UNKNOWN_EFFECT, unknown.kind()); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1, 4, Integer.MAX_VALUE}) + void retriesCannotBeUnbounded(int attempts) { + assertThrows(IllegalArgumentException.class, () -> new RetryPolicy(attempts, Duration.ofSeconds(2), Duration.ofSeconds(4))); + } + + @Test + void retryBoundsAndBackoffOrderAreEnforced() { + assertEquals(3, RetryPolicy.DEFAULT.maxAttempts()); + assertThrows(IllegalArgumentException.class, () -> new RetryPolicy(3, Duration.ofSeconds(2), Duration.ofSeconds(1))); + assertThrows(IllegalArgumentException.class, () -> new RetryPolicy(3, Duration.ZERO, Duration.ofSeconds(4))); + assertThrows(IllegalArgumentException.class, () -> new RetryPolicy(3, Duration.ofSeconds(2), Duration.ofSeconds(121))); + } + + @Test + void retiredFlowRemainsExecutableButNotAssignable() { + var version = new FlowVersion(FLOW, definition(), FlowVersion.Status.RETIRED, 2, + Optional.of("a".repeat(64)), NOW, "editor", Optional.of(NOW)); + assertTrue(version.executable()); assertFalse(version.assignable()); + var request = new RequestInstance(REQUEST, "TYPE", FLOW, "CUSTOM_STATE", EMPTY, "owner", "scope", 0, NOW, NOW); + assertEquals(version.id(), request.flowId()); + assertThrows(IllegalArgumentException.class, () -> new FlowVersion(FLOW, definition(), FlowVersion.Status.PUBLISHED, + 0, Optional.empty(), NOW, "editor", Optional.empty())); + assertThrows(IllegalArgumentException.class, () -> new FlowVersion(FLOW, definition(), FlowVersion.Status.DRAFT, + 0, Optional.of("a".repeat(64)), NOW, "editor", Optional.empty())); + } + + @ParameterizedTest + @EnumSource(ExecutionStatus.class) + void activeExecutionIncludesReconciliationAndExcludesOnlyTerminalStatuses(ExecutionStatus status) { + assertEquals(!Set.of(ExecutionStatus.SUCCEEDED, ExecutionStatus.FAILED).contains(status), status.active()); + } + + private Execution execution(ExecutionStatus status, Optional lease, + Optional result) { + return new Execution(new ExecutionId(UUID.randomUUID()), REQUEST, "key", "a".repeat(64), ACTOR, + "EVENT", EMPTY, "transition", "STATE", 7, status, 1, NOW, lease, 1, result, Optional.empty(), NOW, NOW); + } + + @Test + void executionRequiresConsistentLeaseAndSuccessfulResult() { + var lease = Optional.of(new Execution.Lease("worker", NOW.plusSeconds(60))); + assertThrows(IllegalArgumentException.class, () -> execution(ExecutionStatus.RUNNING, Optional.empty(), Optional.empty())); + assertThrows(IllegalArgumentException.class, () -> execution(ExecutionStatus.PENDING, lease, Optional.empty())); + assertThrows(IllegalArgumentException.class, () -> execution(ExecutionStatus.SUCCEEDED, Optional.empty(), Optional.empty())); + assertEquals(lease, execution(ExecutionStatus.RUNNING, lease, Optional.empty()).lease()); + var result = new Execution.BusinessResult("CLIENTE_NO_ENCONTRADO", "STATE", EMPTY); + var completed = execution(ExecutionStatus.SUCCEEDED, Optional.empty(), Optional.of(result)); + assertEquals(completed.fromState(), completed.result().orElseThrow().toState()); + assertFalse(completed.status().active()); + } + + @Test + void actionCheckpointCannotClaimSuccessWithoutDurableResultAndFinishTime() { + assertThrows(IllegalArgumentException.class, () -> new ActionExecution(new ActionExecutionId(UUID.randomUUID()), + new ExecutionId(UUID.randomUUID()), "action", ActionExecution.Status.SUCCEEDED, 1, "stable-key", + "a".repeat(64), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(NOW), Optional.of(NOW))); + var checkpoint = new ActionExecution(new ActionExecutionId(UUID.randomUUID()), new ExecutionId(UUID.randomUUID()), + "action", ActionExecution.Status.SUCCEEDED, 2, "stable-key", "a".repeat(64), Optional.of(EMPTY), + Optional.empty(), Optional.empty(), Optional.of(NOW), Optional.of(NOW)); + assertEquals("stable-key", checkpoint.externalIdempotencyKey()); + } + + @Test + void actorRolesAreAnImmutableTrustedSnapshot() { + var roles = new HashSet<>(Set.of("ANALISTA")); + var actor = new Actor("subject", "scope", roles); roles.clear(); + assertEquals(Set.of("ANALISTA"), actor.roles()); + assertThrows(UnsupportedOperationException.class, () -> actor.roles().add("FLOW_ADMIN")); + assertEquals(actor, new EvaluationContext(EMPTY, EMPTY, EMPTY, actor).actor()); + } + + @Test + void schemasTasksAndValidationReportsRetainTypedBoundaries() { + var schema = new InlineSchema(new BooleanValue(false)); + assertThrows(IllegalArgumentException.class, () -> new InlineSchema(NullValue.INSTANCE)); + var ui = new TaskDefinition.Ui.DocumentUpload("Upload", "NEW_DOCUMENT", Set.of("application/pdf"), 1000); + assertEquals(schema, new TaskDefinition("STATE", "EVENT", ui, schema).inputSchema()); + assertThrows(IllegalArgumentException.class, () -> new TaskDefinition.Ui.DocumentUpload("Upload", "DOC", Set.of(), 10)); + var warning = new ValidationReport.Issue("/states/0", "UNREACHABLE_STATE", ValidationReport.Severity.WARNING, "Unreachable"); + var error = new ValidationReport.Issue("/states/0", "UNREACHABLE_STATE", ValidationReport.Severity.ERROR, "Unreachable"); + assertTrue(new ValidationReport(List.of(warning)).valid()); + assertFalse(new ValidationReport(List.of(warning, error)).valid()); + assertThrows(IllegalArgumentException.class, () -> new ValidationReport.Issue("/~2", "INVALID", ValidationReport.Severity.ERROR, "Invalid")); + } + + @Test + void domainErrorsExposeStableCodesWithoutPayloadsOrHttpStatus() { + var failure = new DomainException(ErrorCode.IDEMPOTENCY_CONFLICT); + assertEquals(ErrorCode.IDEMPOTENCY_CONFLICT, failure.code()); + assertEquals("IDEMPOTENCY_CONFLICT", failure.getMessage()); + } + + @ParameterizedTest + @ValueSource(strings = {"ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD", "abc", ""}) + void checksumsMatchTheLowercaseDatabaseAndApiContract(String hash) { + assertThrows(IllegalArgumentException.class, () -> new FlowVersion(FLOW, definition(), + FlowVersion.Status.PUBLISHED, 1, Optional.of(hash), NOW, "editor", Optional.of(NOW))); + assertThrows(IllegalArgumentException.class, () -> new ActionExecution(new ActionExecutionId(UUID.randomUUID()), + new ExecutionId(UUID.randomUUID()), "action", ActionExecution.Status.PENDING, 0, "key", + hash, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + assertThrows(IllegalArgumentException.class, () -> new Execution(new ExecutionId(UUID.randomUUID()), + REQUEST, "key", hash, ACTOR, "EVENT", EMPTY, "transition", "STATE", 0, + ExecutionStatus.PENDING, 0, NOW, Optional.empty(), 0, Optional.empty(), Optional.empty(), NOW, NOW)); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/EventAcceptanceIT.java b/src/test/java/com/organizacion/orchestrator/EventAcceptanceIT.java new file mode 100644 index 0000000..fcecc7a --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/EventAcceptanceIT.java @@ -0,0 +1,160 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.execution.application.Events; +import com.organizacion.orchestrator.execution.domain.ExecutionStatus; +import com.organizacion.orchestrator.execution.infrastructure.PostgresEventStore; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import org.junit.jupiter.api.*; +import java.net.URI; +import java.net.http.*; +import java.util.*; +import java.util.concurrent.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static org.junit.jupiter.api.Assertions.*; + +class EventAcceptanceIT { + @BeforeAll static void setup()throws Exception{ + RequestCreationIT.setup(); + admin.execute("CREATE FUNCTION "+schema+".fail_event_insert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.idempotency_key='fail-execution' THEN RAISE EXCEPTION 'test execution failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_event_insert BEFORE INSERT ON "+schema+".execution FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_event_insert()"); + admin.execute("CREATE FUNCTION "+schema+".fail_event_revision() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.data->>'client'='fail-update' AND NEW.revision>OLD.revision THEN RAISE EXCEPTION 'test revision failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_event_revision BEFORE UPDATE ON "+schema+".request_instance FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_event_revision()"); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + static String key(){return RequestCreationIT.unique();} + static UUID request(String data)throws Exception{ + String type=RequestCreationIT.type(RequestCreationIT.publish(1)); + var response=RequestCreationIT.post(type,key(),data,RequestCreationIT.jwt());assertEquals(201,response.statusCode(),response.body()); + return UUID.fromString(json.readTree(response.body()).get("id").asText()); + } + static HttpResponse event(UUID id,String key,String match,String body,String jwt)throws Exception{ + var b=HttpRequest.newBuilder(URI.create(base+"/api/v1/requests/"+id+"/events")).timeout(java.time.Duration.ofSeconds(25)).header("Content-Type","application/json"); + if(key!=null)b.header("Idempotency-Key",key);if(match!=null)b.header("If-Match",match);if(jwt!=null)b.header("Authorization","Bearer "+jwt); + return http.send(b.POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + } + static HttpResponse event(UUID id,String key,long rev)throws Exception{return event(id,key,"\""+rev+"\"","{\"event\":\"SUBMIT\",\"data\":{}}",RequestCreationIT.jwt());} + static long revision(UUID id){return admin.queryForObject("SELECT revision FROM "+schema+".request_instance WHERE id=?",Long.class,id);} + static long count(String table,UUID id){return admin.queryForObject("SELECT count(*) FROM "+schema+"."+table+" WHERE request_id=?",Long.class,id);} + static void code(int status,String code,HttpResponse response)throws Exception{ + assertEquals(status,response.statusCode(),response.body());RequestCreationIT.contract(response,"Problem");assertEquals(code,json.readTree(response.body()).get("code").asText()); + } + @Test void acceptanceIsAtomicPendingAndNeverExecutesBusinessTransition()throws Exception{ + UUID id=request("{\"client\":\"C1\"}");var accepted=event(id,key(),0);assertEquals(202,accepted.statusCode(),accepted.body());RequestCreationIT.contract(accepted,"AcceptedEvent"); + UUID execution=UUID.fromString(json.readTree(accepted.body()).get("executionId").asText()); + assertEquals("/api/v1/executions/"+execution,accepted.headers().firstValue("Location").orElseThrow()); + assertEquals(1,revision(id));assertEquals(1,count("execution",id));assertEquals(2,count("audit_entry",id)); + var row=admin.queryForMap("SELECT * FROM "+schema+".execution WHERE id=?",execution); + assertEquals("PENDING",row.get("status"));assertEquals(1L,row.get("accepted_revision"));assertEquals(0,row.get("attempts"));assertNull(row.get("result")); + assertEquals("submit",row.get("transition_id"));assertEquals("START",row.get("from_state")); + assertEquals(0L,admin.queryForObject("SELECT count(*) FROM "+schema+".action_execution WHERE execution_id=?",Long.class,execution)); + var current=call("GET","/api/v1/requests/"+id,RequestCreationIT.jwt(),null,null);assertEquals("START",json.readTree(current.body()).get("currentState").asText()); + assertEquals("C1",json.readTree(current.body()).at("/data/client").asText());assertEquals(execution.toString(),json.readTree(current.body()).get("activeExecutionId").asText()); + var snapshot=json.readTree(row.get("actor_snapshot").toString());assertEquals(Set.of("subject","businessScope","roles"),new HashSet<>(snapshot.properties().stream().map(Map.Entry::getKey).toList())); + assertEquals("owner",snapshot.get("subject").asText());assertFalse(snapshot.toString().contains("Bearer")); + } + @Test void replayPrecedesRevisionBusyStateRoleAndGuardChecks()throws Exception{ + UUID id=request("{}");String key=key();var first=event(id,key,0);assertEquals(202,first.statusCode()); + assertEquals(first.body(),event(id,key,0).body()); + code(409,"IDEMPOTENCY_CONFLICT",event(id,key,1)); + code(409,"IDEMPOTENCY_CONFLICT",event(id,key,"\"0\"","{\"event\":\"OTHER\",\"data\":{}}",RequestCreationIT.jwt())); + UUID execution=UUID.fromString(json.readTree(first.body()).get("executionId").asText()); + admin.update("UPDATE "+schema+".execution SET status='SUCCEEDED',result='{}'::jsonb WHERE id=?",execution); + admin.update("UPDATE "+schema+".request_instance SET current_state='END',revision=2 WHERE id=?",id); + var replay=event(id,key,"\"0\"","{\"data\":{},\"event\":\"SUBMIT\"}",token("UNRELATED","scope-a","")); + assertEquals(202,replay.statusCode(),replay.body());assertEquals("SUCCEEDED",json.readTree(replay.body()).get("status").asText()); + assertEquals(execution.toString(),json.readTree(replay.body()).get("executionId").asText());assertEquals(1,count("execution",id));assertEquals(2,count("audit_entry",id)); + var freshStore=new PostgresEventStore(app.getBean(javax.sql.DataSource.class),FlowFixtures.CODEC,schema); + assertEquals(ExecutionStatus.SUCCEEDED,freshStore.accept(new RequestId(id),"SUBMIT",ObjectValue.empty(),key,0,RequestCreationIT.owner,"restart-adapter").status()); + } + @Test void ownerScopeAndTransitionRolesCannotBeForged()throws Exception{ + UUID id=request("{}");String body="{\"event\":\"SUBMIT\",\"data\":{}}"; + assertEquals(401,event(id,key(),"\"0\"",body,null).statusCode()); + for(String foreign:List.of(token("ANALISTA","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner"))) + code(404,"NOT_FOUND",event(id,key(),"\"0\"",body,foreign)); + code(403,"ACCESS_DENIED",event(id,key(),"\"0\"",body,token("FLOW_ADMIN","scope-a",""))); + assertEquals(400,event(id,key(),"\"0\"","{\"event\":\"SUBMIT\",\"data\":{},\"roles\":[\"ANALISTA\"]}",RequestCreationIT.jwt()).statusCode()); + code(404,"NOT_FOUND",event(UUID.randomUUID(),key(),0));assertEquals(0,revision(id));assertEquals(0,count("execution",id)); + } + @Test void headersSchemaAndInvalidStateRejectWithoutConsumingKey()throws Exception{ + UUID id=request("{}");String key=key(),jwt=RequestCreationIT.jwt(),body="{\"event\":\"SUBMIT\",\"data\":{}}"; + assertEquals(428,event(id,key,null,body,jwt).statusCode());assertEquals(400,event(id,null,"\"0\"",body,jwt).statusCode()); + for(String match:List.of("*","0","W/\"0\"","\"0\",\"1\"","\"999999999999999999999\""))assertEquals(400,event(id,key,match,body,jwt).statusCode()); + code(412,"REVISION_MISMATCH",event(id,key,5)); + code(409,"INVALID_EVENT",event(id,key,"\"0\"","{\"event\":\"UNKNOWN\",\"data\":{}}",jwt)); + code(422,"SCHEMA_VALIDATION_ERROR",event(id,key,"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"score\":99}}",jwt)); + assertEquals(400,event(id,key,"\"0\"","{\"event\":\"SUBMIT\",\"data\":{},\"data\":{}}",jwt).statusCode()); + assertEquals(413,event(id,key,"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"x\":\""+"x".repeat(262144)+"\"}}",jwt).statusCode()); + assertEquals(0,revision(id));assertEquals(0,count("execution",id));assertEquals(202,event(id,key,0).statusCode()); + } + @Test void allActiveStatusesBlockAnotherCommandButFinishedStatusesDoNot()throws Exception{ + UUID id=request("{}");String key=key();assertEquals(202,event(id,key,0).statusCode()); + for(String status:List.of("PENDING","RUNNING","RETRY_WAIT","RECONCILIATION_REQUIRED")){ + admin.update("UPDATE "+schema+".execution SET status=?,lease_owner=CASE WHEN ?='RUNNING' THEN 'worker' ELSE NULL END,lease_until=CASE WHEN ?='RUNNING' THEN now()+interval '60 seconds' ELSE NULL END WHERE request_id=?",status,status,status,id); + code(409,"REQUEST_BUSY",event(id,key(),1));assertEquals(202,event(id,key,0).statusCode()); + } + admin.update("UPDATE "+schema+".execution SET status='FAILED',lease_owner=NULL,lease_until=NULL WHERE request_id=?",id); + assertEquals(202,event(id,key(),1).statusCode());assertEquals(2,revision(id)); + } + @Test void guardsFailClosedAndPinnedRetiredFlowStillAccepts()throws Exception{ + var n=FlowFixtures.simple();n.put("flowKey",key()); + ((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0")).set("guard",FlowFixtures.node("{\"op\":\"EQ\",\"left\":{\"path\":\"/request/data/client\"},\"right\":{\"literal\":\"allowed\"}}")); + var draft=RequestCreationIT.flows.create(n.toString(),RequestCreationIT.manager,"create");var published=RequestCreationIT.flows.publish(draft.id(),0,RequestCreationIT.manager,"publish");String type=RequestCreationIT.type(published); + var ids=new ArrayList();for(String data:List.of("{\"client\":\"denied\"}","{}","{\"client\":\"allowed\"}")) + ids.add(UUID.fromString(json.readTree(RequestCreationIT.post(type,key(),data,RequestCreationIT.jwt()).body()).get("id").asText())); + code(422,"GUARD_NOT_SATISFIED",event(ids.get(0),key(),0));code(422,"EVALUATION_ERROR",event(ids.get(1),key(),0)); + RequestCreationIT.flows.retire(published.id(),published.revision(),RequestCreationIT.manager,"retire"); + assertEquals(202,event(ids.get(2),key(),0).statusCode());assertEquals(0,revision(ids.get(0)));assertEquals(0,revision(ids.get(1))); + } + @Test void concurrentIdenticalAndDifferentKeysHaveOneAcceptance()throws Exception{ + for(boolean same:List.of(true,false)){ + UUID id=request("{}");String key=key();var barrier=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + var a=pool.submit(()->{barrier.await(10,TimeUnit.SECONDS);return event(id,key,0);}); + var b=pool.submit(()->{barrier.await(10,TimeUnit.SECONDS);return event(id,same?key:key(),0);}); + var x=a.get(30,TimeUnit.SECONDS);var y=b.get(30,TimeUnit.SECONDS); + if(same){assertEquals(202,x.statusCode(),x.body());assertEquals(x.body(),y.body());} + else assertEquals(Set.of(202,412),Set.of(x.statusCode(),y.statusCode())); + } + assertEquals(1,revision(id));assertEquals(1,count("execution",id));assertEquals(2,count("audit_entry",id)); + } + } + @Test void failuresAtInsertRevisionAndAuditRollbackEverything()throws Exception{ + for(String failure:List.of("fail-execution","fail-update","fail-audit")){ + UUID id=request(failure.equals("fail-update")?"{\"client\":\"fail-update\"}":"{}");String key=failure.equals("fail-execution")?failure:key(); + assertThrows(org.springframework.dao.DataAccessException.class,()->app.getBean(Events.class).accept(new RequestId(id),"SUBMIT",ObjectValue.empty(),key,0,RequestCreationIT.owner,failure)); + assertEquals(0,revision(id));assertEquals(0,count("execution",id));assertEquals(1,count("audit_entry",id)); + if(failure.equals("fail-audit"))assertEquals(202,event(id,key,0).statusCode()); + } + UUID id=request("{}");code(503,"SERVICE_UNAVAILABLE",event(id,"fail-execution",0)); + } + @Test void concurrentDifferentPayloadsConflictAndNumericCanonicalReplayIsStable()throws Exception{ + var n=FlowFixtures.simple();n.put("flowKey",key()); + ((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/events/0")).set("inputSchema",FlowFixtures.node("{\"type\":\"object\",\"properties\":{\"amount\":{\"type\":\"number\"}},\"required\":[\"amount\"],\"additionalProperties\":false}")); + var draft=RequestCreationIT.flows.create(n.toString(),RequestCreationIT.manager,"create");var flow=RequestCreationIT.flows.publish(draft.id(),0,RequestCreationIT.manager,"publish"); + String type=RequestCreationIT.type(flow),key=key(),jwt=RequestCreationIT.jwt();UUID id=UUID.fromString(json.readTree(RequestCreationIT.post(type,key(),"{}",jwt).body()).get("id").asText()); + var barrier=new CyclicBarrier(2);HttpResponse a,b; + try(var pool=Executors.newFixedThreadPool(2)){ + var x=pool.submit(()->{barrier.await(10,TimeUnit.SECONDS);return event(id,key,"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"amount\":1}}",jwt);}); + var y=pool.submit(()->{barrier.await(10,TimeUnit.SECONDS);return event(id,key,"\"0\"","{\"event\":\"SUBMIT\",\"data\":{\"amount\":2}}",jwt);}); + a=x.get(30,TimeUnit.SECONDS);b=y.get(30,TimeUnit.SECONDS); + } + assertEquals(Set.of(202,409),Set.of(a.statusCode(),b.statusCode()));code(409,"IDEMPOTENCY_CONFLICT",a.statusCode()==409?a:b); + int winner=a.statusCode()==202?1:2; + assertEquals((a.statusCode()==202?a:b).body(),event(id,key,"\"0\"","{\"data\":{\"amount\":"+winner+".00},\"event\":\"SUBMIT\"}",jwt).body()); + assertEquals(1,revision(id));assertEquals(1,count("execution",id)); + } + @Test void configuredIntegrationIsRecordedButNotInvokedDuringAcceptance()throws Exception{ + var n=FlowFixtures.integration();n.put("flowKey",key()); + var draft=RequestCreationIT.flows.create(n.toString(),RequestCreationIT.manager,"create"); + var flow=RequestCreationIT.flows.publish(draft.id(),0,RequestCreationIT.manager,"publish");String type=RequestCreationIT.type(flow); + UUID id=UUID.fromString(json.readTree(RequestCreationIT.post(type,key(),"{\"client\":\"C1\"}",RequestCreationIT.jwt()).body()).get("id").asText()); + var response=event(id,key(),0);assertEquals(202,response.statusCode(),response.body()); + UUID execution=UUID.fromString(json.readTree(response.body()).get("executionId").asText()); + assertEquals(0L,admin.queryForObject("SELECT count(*) FROM "+schema+".action_execution WHERE execution_id=?",Long.class,execution)); + assertEquals("PENDING",admin.queryForObject("SELECT status FROM "+schema+".execution WHERE id=?",String.class,execution)); + var current=json.readTree(call("GET","/api/v1/requests/"+id,RequestCreationIT.jwt(),null,null).body()); + assertEquals("START",current.get("currentState").asText());assertFalse(current.get("data").has("result")); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/FlowFixtures.java b/src/test/java/com/organizacion/orchestrator/FlowFixtures.java new file mode 100644 index 0000000..fd65586 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/FlowFixtures.java @@ -0,0 +1,60 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.*; +import java.util.*; + +final class FlowFixtures { + static final FlowJsonCodec CODEC=new FlowJsonCodec(); + static final ObjectMapper JSON=new ObjectMapper(); + static JsonNode node(String json){try{return JSON.readTree(json);}catch(Exception e){throw new IllegalArgumentException(e);}} + static InlineSchema schema(String json){return new InlineSchema(CODEC.value(node(json)));} + static final String SIMPLE=""" + {"schemaVersion":1,"flowKey":"FLOW","version":1,"initialState":"START", + "dataSchema":{"type":"object","properties":{"client":{"type":"string"}},"additionalProperties":false}, + "states":[{"key":"START","terminal":false},{"key":"END","terminal":true}], + "events":[{"key":"SUBMIT","inputSchema":{"type":"object","additionalProperties":false}}], + "transitions":[{"id":"submit","from":"START","event":"SUBMIT","allowedRoles":["ANALISTA"], + "actions":[],"to":"END","outcome":"OK"}],"decisions":[],"tasks":[]} + """; + static ObjectNode simple(){return (ObjectNode)node(SIMPLE);} + static ObjectNode integration(){ + var n=simple(); + n.set("initialDataSchema",n.get("dataSchema").deepCopy()); + ((ObjectNode)n.at("/dataSchema/properties")).set("result",node(""" + {"type":"object","properties":{"score":{"type":"number"}},"required":["score"],"additionalProperties":false} + """)); + ((ArrayNode)n.at("/transitions/0/actions")).add(node(""" + {"id":"lookup","type":"INVOKE_INTEGRATION","integrationKey":"LOOKUP","profile":"BASIC", + "inputs":{"client":{"path":"/request/data/client"}},"resultKey":"lookup"} + """)); + ((ObjectNode)n.at("/transitions/0")).set("updates",node(""" + [{"target":"/result","value":{"path":"/results/lookup"}}] + """)); + return n; + } + static PublicationCatalog catalog(){ + var any=schema("{}"); + var input=schema(""" + {"type":"object","properties":{"client":{"type":"string"}},"required":["client"],"additionalProperties":false} + """); + var output=schema(""" + {"type":"object","properties":{"score":{"type":"number"}},"required":["score"],"additionalProperties":false} + """); + var contract=new PublicationCatalog.Contract(input,output,ActionCapability.Effect.READ_ONLY, + Map.of("BASIC",Set.of("1","2")),Map.of("BASIC","1")); + var documentInput=schema(""" + {"type":"object","properties":{"documentId":{"type":"string","format":"uuid"}},"required":["documentId"],"additionalProperties":false} + """); + return new PublicationCatalog(Set.of("ANALISTA"),Map.of( + "INVOKE_INTEGRATION",new ActionCapability("INVOKE_INTEGRATION",ActionCapability.Effect.READ_ONLY,any,input,output), + "REGISTER_DOCUMENT",new ActionCapability("REGISTER_DOCUMENT",ActionCapability.Effect.READ_ONLY,any,documentInput,any), + "NO_OP",new ActionCapability("NO_OP",ActionCapability.Effect.READ_ONLY,any,any,any)), + Map.of("LOOKUP",new PublicationCatalog.Integration("1",Map.of("1",contract,"2",contract))), + Map.of("DOCUMENT",new PublicationCatalog.DocumentPolicy(Set.of("application/pdf"),1000))); + } + static ConfiguredFlowValidator validator(){return new ConfiguredFlowValidator(catalog(),CODEC);} +} diff --git a/src/test/java/com/organizacion/orchestrator/FlowPublicationIT.java b/src/test/java/com/organizacion/orchestrator/FlowPublicationIT.java new file mode 100644 index 0000000..d47b9fc --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/FlowPublicationIT.java @@ -0,0 +1,225 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.flow.application.*; +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.*; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.postgresql.PostgreSQLContainer; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.FlowFixtures.*; + +/** Real transactions, barriers and deliberate DB audit failure; only an owned isolated schema. */ +class FlowPublicationIT { + static final String SCHEMA="t06_"+UUID.randomUUID().toString().replace("-",""); + static final Actor ADMIN=new Actor("verified-admin","scope",Set.of("FLOW_EDITOR","FLOW_PUBLISHER","FLOW_ADMIN")); + static DriverManagerDataSource dataSource;static JdbcTemplate jdbc;static PostgresFlowStore store; + static FlowAdministration service;static PostgreSQLContainer container;static boolean created; + static String table(String name){return "\""+SCHEMA+"\"."+name;} + @BeforeAll static void setup(){ + String url,user,password; + if(Boolean.getBoolean("migration.test.external")){ + url=Objects.requireNonNull(System.getenv("MIGRATION_TEST_DB_URL")); + assertTrue(url.matches("jdbc:postgresql://[A-Za-z0-9.-]+:[0-9]+/orquestador_test")); + user=Objects.requireNonNull(System.getenv("MIGRATION_TEST_DB_USER")); + password=Objects.requireNonNull(System.getenv("MIGRATION_TEST_DB_PASSWORD")); + }else{ + container=new PostgreSQLContainer("postgres:9.6.24").withDatabaseName("orquestador_test") + .withUsername("migration_owner").withPassword("test-only-password");container.start(); + url=container.getJdbcUrl();user=container.getUsername();password=container.getPassword(); + } + dataSource=new DriverManagerDataSource(url,user,password); + var properties=new Properties();properties.setProperty("connectTimeout","10");properties.setProperty("socketTimeout","30"); + properties.setProperty("ApplicationName","orquestador-t06-tests");properties.setProperty("options","-c lock_timeout=15000 -c statement_timeout=20000"); + dataSource.setConnectionProperties(properties);jdbc=new JdbcTemplate(dataSource);jdbc.setQueryTimeout(20); + assertEquals("orquestador_test",jdbc.queryForObject("SELECT current_database()",String.class)); + assertTrue(SCHEMA.matches("t06_[a-f0-9]{32}"));jdbc.execute("CREATE SCHEMA \""+SCHEMA+"\"");created=true; + Flyway.configure().dataSource(dataSource).schemas(SCHEMA).defaultSchema(SCHEMA).createSchemas(false).cleanDisabled(true) + .placeholders(Map.of("orchestratorSchema",SCHEMA)).load().migrate(); + // Test-only fault injection: actual PostgreSQL statement error AFTER business writes. + jdbc.execute("CREATE FUNCTION "+table("test_audit_failure")+"() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN " + +"IF NEW.correlation_id='fail_audit' THEN RAISE EXCEPTION 'test audit failure'; END IF; RETURN NEW; END; $$"); + jdbc.execute("CREATE TRIGGER test_audit_failure BEFORE INSERT ON "+table("admin_audit") + +" FOR EACH ROW EXECUTE PROCEDURE "+table("test_audit_failure")+"()"); + store=new PostgresFlowStore(dataSource,CODEC,SCHEMA); + service=new FlowAdministration(store,new CatalogFlowPreparation(CODEC,validator())); + } + @AfterAll static void cleanup(){ + try{if(created){ + assertEquals("orquestador_test",jdbc.queryForObject("SELECT current_database()",String.class)); + assertTrue(SCHEMA.matches("t06_[a-f0-9]{32}"));jdbc.execute("DROP SCHEMA \""+SCHEMA+"\" CASCADE"); + }}finally{if(container!=null)container.stop();} + } + private FlowVersion draft(){ + var n=simple();n.put("flowKey","F_"+UUID.randomUUID().toString().replace("-","")); + return service.create(n.toString(),ADMIN,"create"); + } + private FlowVersion published(){var flow=draft();return service.publish(flow.id(),flow.revision(),ADMIN,"publish");} + private String type(){ + String key="T_"+UUID.randomUUID().toString().replace("-",""); + jdbc.update("INSERT INTO "+table("request_type")+"(type_key) VALUES (?)",key);return key; + } + private FlowVersion read(FlowId id){return store.transaction(tx->tx.lock(id));} + private long auditCount(FlowId id){return jdbc.queryForObject("SELECT count(*) FROM "+table("admin_audit")+" WHERE flow_id=?",Long.class,id.value());} + private static void code(ErrorCode expected,Runnable action){assertEquals(expected,assertThrows(DomainException.class,action::run).code());} + + @Test void lifecycleRevisionsAuditAndVersionPinning(){ + var n=integration();n.put("flowKey","F_"+UUID.randomUUID().toString().replace("-","")); + var draft=service.create(n.toString(),ADMIN,"create"); + var edited=service.edit(draft.id(),0,n.toString(),ADMIN,"edit");assertEquals(1,edited.revision()); + code(ErrorCode.REVISION_CONFLICT,()->service.edit(draft.id(),0,n.toString(),ADMIN,"stale")); + var flow=service.publish(draft.id(),1,ADMIN,"publish"); + assertEquals(2,flow.revision());assertEquals(CODEC.checksum(flow.definition()),flow.checksum().orElseThrow()); + assertEquals("1",CODEC.tree(flow.definition()).at("/transitions/0/actions/0/contractVersion").asText()); + assertEquals("1",CODEC.tree(flow.definition()).at("/transitions/0/actions/0/profileVersion").asText()); + code(ErrorCode.INVALID_FLOW,()->service.edit(flow.id(),flow.revision(),n.toString(),ADMIN,"illegal")); + code(ErrorCode.INVALID_FLOW,()->service.publish(flow.id(),flow.revision(),ADMIN,"again")); + String type=type();var active=service.activate(type,flow.id(),0,ADMIN,"activate");assertEquals(1,active.revision()); + var retired=service.retire(flow.id(),2,ADMIN,"retire"); + assertEquals(flow.checksum(),retired.checksum());assertEquals(flow.definition(),retired.definition()); + assertTrue(retired.executable());assertFalse(retired.assignable()); + assertEquals(2,store.transaction(tx->tx.lockType(type)).revision()); + assertTrue(store.transaction(tx->tx.lockType(type)).activeFlowId().isEmpty()); + code(ErrorCode.INVALID_FLOW,()->service.activate(type,retired.id(),2,ADMIN,"no")); + assertEquals(List.of("CREATE","EDIT","PUBLISH","ACTIVATE","RETIRE"),jdbc.queryForList( + "SELECT action FROM "+table("admin_audit")+" WHERE flow_id=? ORDER BY created_at,id",String.class,flow.id().value())); + } + @Test void invalidPublicationAndForbiddenRolesNeverWrite(){ + var flow=draft();var n=CODEC.tree(flow.definition()).deepCopy();((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0")).put("to","UNKNOWN"); + var edited=service.edit(flow.id(),0,n.toString(),ADMIN,"edit"); + assertThrows(FlowValidationException.class,()->service.publish(flow.id(),edited.revision(),ADMIN,"bad")); + assertEquals(FlowVersion.Status.DRAFT,read(flow.id()).status());assertEquals(2,auditCount(flow.id())); + var nobody=new Actor("other","scope",Set.of("ANALISTA")); + code(ErrorCode.ACCESS_DENIED,()->service.edit(flow.id(),1,n.toString(),nobody,"deny")); + code(ErrorCode.ACCESS_DENIED,()->service.publish(flow.id(),1,nobody,"deny")); + code(ErrorCode.ACCESS_DENIED,()->service.retire(flow.id(),1,nobody,"deny")); + code(ErrorCode.ACCESS_DENIED,()->service.create(SIMPLE,nobody,"deny")); + assertEquals(2,auditCount(flow.id())); + } + @Test void databaseAndApplicationBothPreventPublishedMutation(){ + var flow=published(); + assertThrows(DataAccessException.class,()->jdbc.update("UPDATE "+table("flow_definition")+" SET definition='{}'::jsonb,revision=revision+1 WHERE id=?",flow.id().value())); + assertThrows(DataAccessException.class,()->jdbc.update("DELETE FROM "+table("flow_definition")+" WHERE id=?",flow.id().value())); + code(ErrorCode.INVALID_FLOW,()->service.edit(flow.id(),flow.revision(),CODEC.encode(flow.definition()),ADMIN,"immutable")); + assertEquals(flow,read(flow.id())); + } + @Test void auditFailureRollsBackEveryAdministrativeWrite(){ + var n=simple();n.put("flowKey","FAIL_"+UUID.randomUUID().toString().replace("-","")); + assertThrows(DataAccessException.class,()->service.create(n.toString(),ADMIN,"fail_audit")); + assertEquals(0,jdbc.queryForObject("SELECT count(*) FROM "+table("flow_definition")+" WHERE flow_key=?",Integer.class,n.get("flowKey").asText())); + var draft=draft(); + assertThrows(DataAccessException.class,()->service.edit(draft.id(),0,CODEC.encode(draft.definition()),ADMIN,"fail_audit")); + assertEquals(draft,read(draft.id())); + assertThrows(DataAccessException.class,()->service.publish(draft.id(),0,ADMIN,"fail_audit")); + assertEquals(draft,read(draft.id())); + var flow=service.publish(draft.id(),0,ADMIN,"ok");String type=type(); + assertThrows(DataAccessException.class,()->service.activate(type,flow.id(),0,ADMIN,"fail_audit")); + assertTrue(store.transaction(tx->tx.lockType(type)).activeFlowId().isEmpty()); + service.activate(type,flow.id(),0,ADMIN,"ok"); + assertThrows(DataAccessException.class,()->service.retire(flow.id(),flow.revision(),ADMIN,"fail_audit")); + assertEquals(flow,read(flow.id())); + assertEquals(Optional.of(flow.id()),store.transaction(tx->tx.lockType(type)).activeFlowId()); + assertEquals(1,store.transaction(tx->tx.lockType(type)).revision()); + assertEquals(3,auditCount(flow.id())); + } + private List race(Supplier first,Supplier second)throws Exception{ + var barrier=new CyclicBarrier(2); + try(var executor=Executors.newFixedThreadPool(2)){ + var a=executor.submit(()->attempt(barrier,first));var b=executor.submit(()->attempt(barrier,second)); + return List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS)); + } + } + private String attempt(CyclicBarrier barrier,Supplier action)throws Exception{ + barrier.await(10,TimeUnit.SECONDS); + try{action.get();return "OK";}catch(DomainException e){return e.code().name();} + } + @Test void twoPublishersHaveExactlyOneWinnerAndOneAudit()throws Exception{ + var flow=draft();var outcomes=race(()->service.publish(flow.id(),0,ADMIN,"one"),()->service.publish(flow.id(),0,ADMIN,"two")); + assertEquals(Set.of("OK","REVISION_CONFLICT"),new HashSet<>(outcomes)); + assertEquals(FlowVersion.Status.PUBLISHED,read(flow.id()).status());assertEquals(2,auditCount(flow.id())); + } + @Test void editAndPublicationCannotPublishAnUnreviewedRevision()throws Exception{ + var flow=draft();var outcomes=race(()->service.edit(flow.id(),0,CODEC.encode(flow.definition()),ADMIN,"edit"), + ()->service.publish(flow.id(),0,ADMIN,"publish")); + assertEquals(Set.of("OK","REVISION_CONFLICT"),new HashSet<>(outcomes)); + assertEquals(1,read(flow.id()).revision());assertEquals(2,auditCount(flow.id())); + } + @Test void concurrentActivationsUseTypeRevision()throws Exception{ + var a=published();var b=published();String type=type(); + assertEquals(Set.of("OK","REVISION_CONFLICT"),new HashSet<>(race( + ()->service.activate(type,a.id(),0,ADMIN,"a"),()->service.activate(type,b.id(),0,ADMIN,"b")))); + var active=store.transaction(tx->tx.lockType(type));assertEquals(1,active.revision()); + assertTrue(Set.of(a.id(),b.id()).contains(active.activeFlowId().orElseThrow())); + } + @Test void activationRetirementRaceNeverLeavesRetiredVersionActive()throws Exception{ + var flow=published();String type=type(); + var outcomes=race(()->service.activate(type,flow.id(),0,ADMIN,"activate"),()->service.retire(flow.id(),flow.revision(),ADMIN,"retire")); + assertEquals("OK",outcomes.get(1));assertTrue(Set.of("OK","INVALID_FLOW").contains(outcomes.getFirst())); + assertEquals(FlowVersion.Status.RETIRED,read(flow.id()).status()); + assertTrue(store.transaction(tx->tx.lockType(type)).activeFlowId().isEmpty()); + code(ErrorCode.INVALID_FLOW,()->store.transaction(tx->tx.assignableFlow(type))); + } + @Test void assignmentBeforeRetirementCompletesAtomicallyAndExistingRequestStaysPinned()throws Exception{ + var flow=published();String type=type();service.activate(type,flow.id(),0,ADMIN,"activate"); + var locked=new CountDownLatch(1);var retireStarted=new CountDownLatch(1);var release=new CountDownLatch(1);UUID request=UUID.randomUUID(); + try(var executor=Executors.newFixedThreadPool(2)){ + var creation=executor.submit(()->store.transaction(tx->{ + var selected=tx.assignableFlow(type);locked.countDown();await(release); + jdbc.update("INSERT INTO "+table("request_instance")+"(id,type_key,flow_id,current_state,owner_subject,business_scope) VALUES (?,?,?,?,?,?)", + request,type,selected.id().value(),selected.definition().initialState(),"owner","scope"); + return selected.id(); + })); + assertTrue(locked.await(10,TimeUnit.SECONDS)); + var retirement=executor.submit(()->{retireStarted.countDown();return service.retire(flow.id(),flow.revision(),ADMIN,"retire");}); + assertTrue(retireStarted.await(10,TimeUnit.SECONDS));release.countDown(); + assertEquals(flow.id(),creation.get(25,TimeUnit.SECONDS));assertEquals(FlowVersion.Status.RETIRED,retirement.get(25,TimeUnit.SECONDS).status()); + }finally{release.countDown();} + assertEquals(flow.id().value(),jdbc.queryForObject("SELECT flow_id FROM "+table("request_instance")+" WHERE id=?",UUID.class,request)); + code(ErrorCode.INVALID_FLOW,()->store.transaction(tx->tx.assignableFlow(type))); + } + @Test void sessionsCannotEscapeTheirTransaction(){ + var escaped=new AtomicReference();store.transaction(tx->{escaped.set(tx);return null;}); + assertThrows(IllegalStateException.class,()->escaped.get().lockType("OTHER")); + } + @Test void retirementBeforeAssignmentPreventsANewRequestEvenWhenTransactionsOverlap()throws Exception{ + var flow=published();String type=type();service.activate(type,flow.id(),0,ADMIN,"activate"); + var locked=new CountDownLatch(1);var assignmentStarted=new CountDownLatch(1);var release=new CountDownLatch(1); + UUID request=UUID.randomUUID(); + try(var executor=Executors.newFixedThreadPool(2)){ + var retirement=executor.submit(()->store.transaction(tx->{ + var retired=service.retire(flow.id(),flow.revision(),ADMIN,"retire"); + locked.countDown();await(release);return retired; + })); + assertTrue(locked.await(10,TimeUnit.SECONDS)); + var creation=executor.submit(()->{ + assignmentStarted.countDown(); + try{ + store.transaction(tx->{ + var selected=tx.assignableFlow(type); + jdbc.update("INSERT INTO "+table("request_instance")+"(id,type_key,flow_id,current_state,owner_subject,business_scope) VALUES (?,?,?,?,?,?)", + request,type,selected.id().value(),"START","owner","scope");return null; + });return "CREATED"; + }catch(DomainException e){return e.code().name();} + }); + assertTrue(assignmentStarted.await(10,TimeUnit.SECONDS));release.countDown(); + assertEquals(FlowVersion.Status.RETIRED,retirement.get(25,TimeUnit.SECONDS).status()); + assertTrue(Set.of("INVALID_FLOW","REVISION_CONFLICT").contains(creation.get(25,TimeUnit.SECONDS))); + }finally{release.countDown();} + assertEquals(0,jdbc.queryForObject("SELECT count(*) FROM "+table("request_instance")+" WHERE id=?",Integer.class,request)); + assertEquals(type,jdbc.queryForObject("SELECT detail->'deactivatedTypeKeys'->>0 FROM "+table("admin_audit")+" WHERE flow_id=? AND action='RETIRE'",String.class,flow.id().value())); + } + private static void await(CountDownLatch latch){ + try{if(!latch.await(10,TimeUnit.SECONDS))throw new IllegalStateException("Barrier timeout");} + catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);} + } +} diff --git a/src/test/java/com/organizacion/orchestrator/FlowSimulationTest.java b/src/test/java/com/organizacion/orchestrator/FlowSimulationTest.java new file mode 100644 index 0000000..2da879d --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/FlowSimulationTest.java @@ -0,0 +1,56 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.FlowSimulation; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.FlowId; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class FlowSimulationTest { + static FlowVersion draft(ObjectNode flow){return new FlowVersion(new FlowId(UUID.randomUUID()),FlowFixtures.CODEC.decode(flow.toString()),FlowVersion.Status.DRAFT,0,Optional.empty(),Instant.EPOCH,"editor",Optional.empty());} + static ObjectNode fixture(){return (ObjectNode)FlowFixtures.node(""" + {"state":"START","requestData":{"client":"synthetic"},"event":{"event":"SUBMIT","data":{}}, + "actor":{"subject":"fixture-only","businessScope":"demo","roles":["ANALISTA"]},"results":{}} + """);} + static FlowSimulation simulation(){return new FlowSimulation(FlowFixtures.catalog(),FlowFixtures.CODEC);} + @ParameterizedTest @ValueSource(ints={71,70,61,60}) void exactCreditScoringUsesSharedProjection(int score)throws Exception { + var flow=(ObjectNode)FlowFixtures.JSON.readTree(Files.readString(Path.of("examples/flows/credito-v1.json"))); + var input=fixture();input.put("state","BURO_PENDIENTE");input.set("requestData",FlowFixtures.node("{\"client\":\"synthetic\",\"consentimientoId\":\"consent\",\"cliente\":{\"found\":true,\"customerId\":\"synthetic\"}}")); + ((ObjectNode)input.get("event")).put("event","VALIDAR_BURO");input.set("results",FlowFixtures.node("{\"buro\":{\"score\":"+score+",\"consultationId\":\"fixture\"}}")); + var service=new FlowSimulation(HttpProviderFixture.exampleCatalog(),FlowFixtures.CODEC); + var result=FlowFixtures.node(service.simulate(draft(flow),input));assertEquals(score>70?"PASO_N":score>60?"PASO_M":"REVISION_MANUAL",result.get("toState").asText()); + assertEquals(score,result.at("/data/validaciones/buro/score").asInt());assertFalse(input.get("requestData").has("validaciones")); + } + @ParameterizedTest @ValueSource(strings={"{}","{\"lookup\":{}}","{\"lookup\":{\"score\":\"60\"}}","{\"lookup\":{\"score\":60},\"extra\":{}}"}) + void missingWrongOrExtraResultsFailClosed(String results){ + var input=fixture();input.set("results",FlowFixtures.node(results));assertThrows(DomainException.class,()->simulation().simulate(draft(FlowFixtures.integration()),input)); + } + @Test void noOpWorksOnlyAsConfiguredSimulationOutput(){ + var flow=FlowFixtures.simple();((ArrayNode)flow.at("/transitions/0/actions")).add(FlowFixtures.node("{\"id\":\"noop\",\"type\":\"NO_OP\",\"resultKey\":\"mock\",\"output\":{\"name\":\"simulated\"}}")); + ((ObjectNode)flow.at("/transitions/0")).set("updates",FlowFixtures.node("[{\"target\":\"/client\",\"value\":{\"path\":\"/results/mock/name\"}}]")); + assertEquals("simulated",FlowFixtures.node(simulation().simulate(draft(flow),fixture())).at("/data/client").asText()); + assertFalse(FlowFixtures.validator().validate(draft(flow).definition()).valid()); + } + @Test void guardRolesAndMissingInputsAreEvaluatedBeforeFixtures(){ + var flow=FlowFixtures.integration();var input=fixture();input.set("results",FlowFixtures.node("{\"lookup\":{\"score\":70}}")); + ((ObjectNode)flow.at("/transitions/0")).set("guard",FlowFixtures.node("{\"op\":\"EQ\",\"left\":{\"path\":\"/request/data/client\"},\"right\":{\"literal\":\"allowed\"}}")); + assertEquals(ErrorCode.GUARD_NOT_SATISFIED,assertThrows(DomainException.class,()->simulation().simulate(draft(flow),input)).code()); + ((ArrayNode)input.at("/actor/roles")).removeAll();assertEquals(ErrorCode.ACCESS_DENIED,assertThrows(DomainException.class,()->simulation().simulate(draft(flow),input)).code()); + } + @Test void inlineReferencesNeverResolveNetworkOrFiles(){ + var flow=FlowFixtures.simple();flow.set("dataSchema",FlowFixtures.node("{\"type\":\"object\",\"$ref\":\"http://127.0.0.1:1/forbidden\"}")); + assertThrows(IllegalArgumentException.class,()->draft(flow)); // DSL rejects references before any simulation/schema resolution. + } + @Test void ambiguousDraftDecisionIsNotConvertedIntoDefault(){ + var flow=FlowFixtures.simple();var transition=(ObjectNode)flow.at("/transitions/0");transition.remove("to");transition.remove("outcome");transition.put("decision","D");transition.set("destinations",FlowFixtures.node("{\"YES\":\"END\",\"NO\":\"START\"}")); + flow.set("decisions",FlowFixtures.node("[{\"id\":\"D\",\"hitPolicy\":\"UNIQUE\",\"rules\":[{\"id\":\"one\",\"when\":{\"op\":\"EQ\",\"left\":{\"literal\":1},\"right\":{\"literal\":1}},\"output\":\"YES\"},{\"id\":\"two\",\"when\":{\"op\":\"EQ\",\"left\":{\"literal\":1},\"right\":{\"literal\":1}},\"output\":\"YES\"}],\"defaultOutput\":\"NO\"}]")); + assertEquals(ErrorCode.AMBIGUOUS_DECISION,assertThrows(DomainException.class,()->simulation().simulate(draft(flow),fixture())).code()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/FlowValidationTest.java b/src/test/java/com/organizacion/orchestrator/FlowValidationTest.java new file mode 100644 index 0000000..4675512 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/FlowValidationTest.java @@ -0,0 +1,157 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.flow.infrastructure.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.FlowFixtures.*; + +class FlowValidationTest { + private final ConfiguredFlowValidator validator=validator(); + record Invalid(String name,Consumer change,String code){} + private void invalid(ObjectNode json,String code){ + var report=validator.validate(CODEC.decode(json.toString())); + assertFalse(report.valid(),code); + assertTrue(report.issues().stream().anyMatch(i->i.code().equals(code)),report.toString()); + report.issues().forEach(i->assertTrue(i.pointer().startsWith("/")||i.pointer().isEmpty())); + } + @TestFactory Stream graphRolesSchemaAndTaskFailures(){ + return Stream.of( + new Invalid("duplicate state",n->((ArrayNode)n.get("states")).add(n.at("/states/0").deepCopy()),"DUPLICATE_ID"), + new Invalid("unknown initial",n->n.put("initialState","MISSING"),"UNKNOWN_STATE"), + new Invalid("unknown source",n->((ObjectNode)n.at("/transitions/0")).put("from","MISSING"),"UNKNOWN_STATE"), + new Invalid("unknown target",n->((ObjectNode)n.at("/transitions/0")).put("to","MISSING"),"UNKNOWN_STATE"), + new Invalid("unknown event",n->((ObjectNode)n.at("/transitions/0")).put("event","OTHER"),"UNKNOWN_EVENT"), + new Invalid("terminal outgoing",n->((ObjectNode)n.at("/states/0")).put("terminal",true),"TERMINAL_HAS_TRANSITION"), + new Invalid("nonterminal sink",n->((ObjectNode)n.at("/states/1")).put("terminal",false),"NON_TERMINAL_WITHOUT_EXIT"), + new Invalid("unreachable",n->((ArrayNode)n.get("states")).add(node("{\"key\":\"ORPHAN\",\"terminal\":true}")),"UNREACHABLE_STATE"), + new Invalid("pair duplicate",n->{var t=n.at("/transitions/0").deepCopy();((ObjectNode)t).put("id","second");((ArrayNode)n.get("transitions")).add(t);},"DUPLICATE_STATE_EVENT"), + new Invalid("unknown role",n->((ArrayNode)n.at("/transitions/0/allowedRoles")).add("UNTRUSTED"),"UNKNOWN_ROLE"), + new Invalid("no data schema",n->n.remove("dataSchema"),"OBJECT_DATA_SCHEMA_REQUIRED"), + new Invalid("open event input",n->((ObjectNode)n.at("/events/0/inputSchema")).put("additionalProperties",true),"CLOSED_OBJECT_INPUT_REQUIRED"), + new Invalid("guard result before actions",n->((ObjectNode)n.at("/transitions/0")).set("guard",node("{\"op\":\"GT\",\"left\":{\"path\":\"/results/later/score\"},\"right\":{\"literal\":70}}")),"UNKNOWN_CONDITION_TYPE"), + new Invalid("task without transition",n->((ArrayNode)n.get("tasks")).add(node(""" + {"state":"END","event":"SUBMIT","inputSchema":{"type":"object","additionalProperties":false},"ui":{"component":"FORM","title":"Task"}} + """)),"TASK_WITHOUT_TRANSITION"), + new Invalid("task mismatch",n->((ArrayNode)n.get("tasks")).add(node(""" + {"state":"START","event":"SUBMIT","inputSchema":false,"ui":{"component":"FORM","title":"Task"}} + """)),"TASK_EVENT_SCHEMA_MISMATCH") + ).map(test->DynamicTest.dynamicTest(test.name(),()->{var n=simple();test.change().accept(n);invalid(n,test.code());})); + } + @TestFactory Stream catalogAndMappingFailures(){ + return Stream.of( + new Invalid("unknown integration",n->((ObjectNode)n.at("/transitions/0/actions/0")).put("integrationKey","OTHER"),"UNKNOWN_CONTRACT_OR_PROFILE"), + new Invalid("unknown contract",n->((ObjectNode)n.at("/transitions/0/actions/0")).put("contractVersion","99"),"UNKNOWN_CONTRACT_OR_PROFILE"), + new Invalid("unknown profile",n->((ObjectNode)n.at("/transitions/0/actions/0")).put("profile","OTHER"),"UNKNOWN_CONTRACT_OR_PROFILE"), + new Invalid("unknown profile version",n->((ObjectNode)n.at("/transitions/0/actions/0")).put("profileVersion","99"),"UNKNOWN_CONTRACT_OR_PROFILE"), + new Invalid("missing input",n->((ObjectNode)n.at("/transitions/0/actions/0/inputs")).remove("client"),"MISSING_ACTION_INPUT"), + new Invalid("unexpected input",n->((ObjectNode)n.at("/transitions/0/actions/0/inputs")).set("other",node("{\"literal\":1}")),"UNKNOWN_MAPPING_PATH"), + new Invalid("wrong literal type",n->((ObjectNode)n.at("/transitions/0/actions/0/inputs")).set("client",node("{\"literal\":1}")),"INVALID_LITERAL_MAPPING"), + new Invalid("forward result",n->((ObjectNode)n.at("/transitions/0/actions/0/inputs")).set("client",node("{\"path\":\"/results/lookup/score\"}")),"UNKNOWN_MAPPING_PATH"), + new Invalid("wrong result type",n->((ObjectNode)n.at("/dataSchema/properties")).set("result",node("{\"type\":\"string\"}")),"INCOMPATIBLE_MAPPING_TYPE"), + new Invalid("update overlap",n->((ArrayNode)n.at("/transitions/0/updates")).add(node("{\"target\":\"/result/score\",\"value\":{\"path\":\"/results/lookup/score\"}}")),"OVERLAPPING_UPDATES"), + new Invalid("initial accepts provider output",n->n.remove("initialDataSchema"),"PROVIDER_DATA_ACCEPTED_FROM_CLIENT"), + new Invalid("event accepts provider output",n->((ObjectNode)n.at("/events/0/inputSchema")).set("properties",node("{\"result\":{}}")),"PROVIDER_DATA_ACCEPTED_FROM_CLIENT"), + new Invalid("duplicate action/result",n->((ArrayNode)n.at("/transitions/0/actions")).add(n.at("/transitions/0/actions/0").deepCopy()),"DUPLICATE_RESULT_KEY") + ).map(test->DynamicTest.dynamicTest(test.name(),()->{var n=integration();test.change().accept(n);invalid(n,test.code());})); + } + @Test void validGraphsAndContractVersionsAreResolvedWithoutProviderAccess(){ + assertTrue(validator.validate(CODEC.decode(SIMPLE)).valid()); + var definition=CODEC.decode(integration().toString());var report=validator.validate(definition); + assertTrue(report.valid(),report.toString()); + var pinned=CODEC.tree(validator.resolve(definition)); + assertEquals("1",pinned.at("/transitions/0/actions/0/contractVersion").asText()); + assertEquals("1",pinned.at("/transitions/0/actions/0/profileVersion").asText()); + var explicit=integration();((ObjectNode)explicit.at("/transitions/0/actions/0")).put("contractVersion","2").put("profileVersion","2"); + assertEquals("2",CODEC.tree(validator.resolve(CODEC.decode(explicit.toString()))).at("/transitions/0/actions/0/contractVersion").asText()); + } + @Test void noOpAndUnregisteredHandlersCannotBePublished(){ + var n=simple();((ArrayNode)n.at("/transitions/0/actions")).add(node("{\"id\":\"fake\",\"type\":\"NO_OP\",\"resultKey\":\"fake\"}")); + invalid(n,"SIMULATION_ONLY_ACTION"); + var empty=new PublicationCatalog(Set.of("ANALISTA"),Map.of(),Map.of(),Map.of()); + assertFalse(new ConfiguredFlowValidator(empty,CODEC).validate(CODEC.decode(integration().toString())).valid()); + } + @Test void decisionsRequireCoveredOutputsAndRejectProvenUniqueOverlap(){ + var n=simple();var t=(ObjectNode)n.at("/transitions/0");t.remove(List.of("to","outcome"));t.put("decision","ROUTE"); + t.set("destinations",node("{\"YES\":\"END\",\"NO\":\"END\"}")); + ((ArrayNode)n.get("decisions")).add(node(""" + {"id":"ROUTE","hitPolicy":"UNIQUE","defaultOutput":"NO","rules":[ + {"id":"first","when":{"op":"GT","left":{"path":"/request/data/score"},"right":{"literal":0}},"output":"YES"}, + {"id":"second","when":{"op":"GT","left":{"path":"/request/data/score"},"right":{"literal":1}},"output":"YES"}]} + """)); + ((ObjectNode)n.at("/dataSchema/properties")).set("score",node("{\"type\":\"number\"}")); + invalid(n,"OVERLAPPING_RULES"); + ((ObjectNode)n.at("/decisions/0")).put("hitPolicy","FIRST"); + assertTrue(validator.validate(CODEC.decode(n.toString())).valid()); + ((ObjectNode)t.get("destinations")).remove("NO");invalid(n,"UNCOVERED_DECISION_OUTPUT"); + } + @Test void codecIsLosslessForDomainAndCanonicalAcrossPropertyOrderAndDecimalScale(){ + var first=CODEC.decode(integration().toString());String encoded=CODEC.encode(first); + assertEquals(first,CODEC.decode(encoded));assertEquals(CODEC.checksum(first),CODEC.checksum(CODEC.decode(encoded))); + assertEquals(CODEC.canonical(node("{\"b\":1.00,\"a\":0e3}")),CODEC.canonical(node("{\"a\":0,\"b\":1}"))); + assertNotEquals(CODEC.canonical(node("[1,2]")),CODEC.canonical(node("[2,1]"))); + assertEquals(64,CODEC.checksum(first).length()); + var edited=integration();((ObjectNode)edited).put("version",2);assertNotEquals(CODEC.checksum(first),CODEC.checksum(CODEC.decode(edited.toString()))); + } + @ParameterizedTest @ValueSource(strings={"{\"schemaVersion\":1,\"schemaVersion\":1}","{} {}","{}","[]","null"}) + void malformedDefinitionsAndDuplicateKeysAreRejected(String json){assertThrows(IllegalArgumentException.class,()->CODEC.decode(json));} + @Test void codecRejectsOversizedDeepAndExecutableDefinitions(){ + assertThrows(IllegalArgumentException.class,()->CODEC.decode(" ".repeat(FlowJsonCodec.MAX_BYTES+1))); + var n=simple();((ObjectNode)n.get("dataSchema")).put("$ref","https://untrusted.invalid/schema"); + assertThrows(IllegalArgumentException.class,()->CODEC.decode(n.toString())); + assertThrows(IllegalArgumentException.class,()->CODEC.decode("[".repeat(1000)+"]".repeat(1000))); + var overflow=simple();overflow.put("version",4294967297L); + assertThrows(IllegalArgumentException.class,()->CODEC.decode(overflow.toString())); + } + @Test void allExistingValidFlowFixturesDecodeAndRoundTrip()throws Exception{ + try(var paths=Files.list(Path.of("examples/contracts/flows"))){ + for(var file:paths.filter(p->p.toString().endsWith(".json")).toList()){ + var f=CODEC.decode(Files.readString(file,StandardCharsets.UTF_8)); + assertEquals(f,CODEC.decode(CODEC.encode(f)),file.toString()); + } + } + } + @Test void schemaContradictionsAndComparisonTypeErrorsAreRejected(){ + var n=simple();((ObjectNode)n.get("dataSchema")).put("minProperties",2).put("maxProperties",1); + invalid(n,"INCONSISTENT_SCHEMA"); + n=simple();((ObjectNode)n.at("/transitions/0")).set("guard",node(""" + {"op":"EQ","left":{"path":"/request/data/client"},"right":{"literal":1}} + """)); + invalid(n,"INCOMPATIBLE_COMPARISON_TYPE"); + n=simple();((ObjectNode)n.at("/transitions/0")).set("guard",node(""" + {"op":"IN","left":{"path":"/request/data/client"},"right":{"literal":["value",1]}} + """)); + invalid(n,"INVALID_MEMBERSHIP_VALUES"); + } + @Test void documentTasksRequireTrustedPolicySchemaAndMatchingAction(){ + var n=simple();var input=node(""" + {"type":"object","properties":{"documentId":{"type":"string","format":"uuid"}},"required":["documentId"],"additionalProperties":false} + """); + ((ObjectNode)n.at("/events/0")).set("inputSchema",input); + ((ArrayNode)n.at("/transitions/0/actions")).add(node(""" + {"id":"document","type":"REGISTER_DOCUMENT","documentType":"DOCUMENT", + "inputs":{"documentId":{"path":"/event/data/documentId"}},"resultKey":"doc"} + """)); + var task=(ObjectNode)node(""" + {"state":"START","event":"SUBMIT","ui":{"component":"DOCUMENT_UPLOAD","title":"Upload", + "documentType":"DOCUMENT","acceptedMimeTypes":["application/pdf"],"maxBytes":1000}} + """); + task.set("inputSchema",input);((ArrayNode)n.get("tasks")).add(task); + assertTrue(validator.validate(CODEC.decode(n.toString())).valid()); + ((ObjectNode)task.get("ui")).put("maxBytes",1001);invalid(n,"DOCUMENT_POLICY_MISMATCH"); + ((ObjectNode)task.get("ui")).put("maxBytes",1000).put("documentType","OTHER");invalid(n,"DOCUMENT_ACTION_MISMATCH"); + } + @Test void canonicalizationRejectsUnicodeThatCannotRoundTripThroughUtf8Jsonb(){ + assertThrows(IllegalArgumentException.class,()->CODEC.canonical(TextNode.valueOf(String.valueOf((char)0xD800)))); + assertThrows(IllegalArgumentException.class,()->CODEC.canonical(TextNode.valueOf(String.valueOf((char)0)))); + assertEquals("\"😀\"",CODEC.canonical(TextNode.valueOf("😀"))); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/HttpIntegrationIT.java b/src/test/java/com/organizacion/orchestrator/HttpIntegrationIT.java new file mode 100644 index 0000000..cf3b451 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/HttpIntegrationIT.java @@ -0,0 +1,86 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.node.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.execution.infrastructure.*; +import com.organizacion.orchestrator.flow.application.FlowAdministration; +import com.organizacion.orchestrator.flow.infrastructure.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; +import static com.organizacion.orchestrator.HttpProviderFixture.*; +import static org.junit.jupiter.api.Assertions.*; + +class HttpIntegrationIT { + @BeforeAll static void setup()throws Exception{ + RequestCreationIT.setup();WorkerIT.store=WorkerIT.fresh(); + RequestCreationIT.flows=new FlowAdministration(RequestCreationIT.flowStore,new CatalogFlowPreparation(FlowFixtures.CODEC,new ConfiguredFlowValidator(exampleCatalog(),FlowFixtures.CODEC))); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @BeforeEach void isolate(){new WorkerIT().isolateQueue();} + static ObjectNode clientFlow(){ + var n=FlowFixtures.simple();n.put("flowKey",unique());n.set("initialDataSchema",n.get("dataSchema").deepCopy()); + ((ObjectNode)n.at("/dataSchema/properties")).set("customer",FlowFixtures.CODEC.tree(exampleCatalog().integrations().get("CLIENTES_CONSULTA").contracts().get("1").output().value())); + var t=(ObjectNode)n.at("/transitions/0");t.remove("to");t.remove("outcome");t.put("decision","FOUND"); + t.set("actions",FlowFixtures.node(""" + [{"id":"client","type":"INVOKE_INTEGRATION","integrationKey":"CLIENTES_CONSULTA","resultKey":"client","inputs":{"client":{"path":"/request/data/client"}}}] + """)); + t.set("updates",FlowFixtures.node(""" + [{"target":"/customer","value":{"path":"/results/client"}}] + """)); + t.set("destinations",FlowFixtures.node("{\"CLIENTE_ENCONTRADO\":\"END\",\"CLIENTE_NO_ENCONTRADO\":\"START\"}")); + n.set("decisions",FlowFixtures.node(""" + [{"id":"FOUND","hitPolicy":"UNIQUE","rules":[{"id":"found","when":{"op":"EQ","left":{"path":"/results/client/found"},"right":{"literal":true}},"output":"CLIENTE_ENCONTRADO"}],"defaultOutput":"CLIENTE_NO_ENCONTRADO"}] + """));return n; + } + @ParameterizedTest @ValueSource(booleans={true,false}) + void normalizedBusinessOutcomeCommitsThroughRealWorker(boolean found)throws Exception{ + try(var p=new HttpProviderFixture()){ + p.body=found?"{\"found\":true,\"customerId\":\"C1\"}":"{\"found\":false}"; + var id=pending(publish(clientFlow()),"{\"client\":\"C1\"}");var token=claim("http-worker"); + engine(p.actions(exampleCatalog(),p.route("CLIENTES_CONSULTA","",2000,2000,Map.of(),Optional.empty(),""))).run(token); + assertEquals("SUCCEEDED",status(id));assertEquals(1,p.calls.get()); + assertEquals(found?"END":"START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertEquals(found?"CLIENTE_ENCONTRADO":"CLIENTE_NO_ENCONTRADO",admin.queryForObject("SELECT result->>'outcome' FROM "+schema+".execution WHERE id=?",String.class,id)); + assertEquals(Boolean.toString(found),admin.queryForObject("SELECT data->'customer'->>'found' FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertEquals("SUCCEEDED",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + } + } + @ParameterizedTest @ValueSource(strings={"timeout","invalid","unavailable"}) + void technicalFailureDoesNotBecomeNegativeBusinessOutcome(String scenario)throws Exception{ + try(var p=new HttpProviderFixture()){ + if(scenario.equals("timeout"))p.delayMs=1000; + else if(scenario.equals("unavailable"))p.status=503; + else p.body="{\"found\":\"false\"}"; + var id=pending(publish(clientFlow()),"{\"client\":\"C1\"}");var token=claim("http-worker"); + engine(p.actions(exampleCatalog(),p.route("CLIENTES_CONSULTA","",200,2000,Map.of(),Optional.empty(),""))).run(token); + assertEquals(scenario.equals("invalid")?"FAILED":"RETRY_WAIT",status(id)); + assertEquals("START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertNull(admin.queryForObject("SELECT data->'customer' FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertNull(admin.queryForObject("SELECT result FROM "+schema+".execution WHERE id=?",String.class,id)); + } + } + @Test void documentReferenceAndCheckpointAreAtomicAndIdempotent()throws Exception{ + try(var p=new HttpProviderFixture()){ + UUID document=UUID.randomUUID();var n=FlowFixtures.simple();n.put("flowKey",unique()); + ((ArrayNode)n.at("/transitions/0/actions")).add(FlowFixtures.node("{\"id\":\"doc\",\"type\":\"REGISTER_DOCUMENT\",\"resultKey\":\"doc\",\"documentType\":\"DOCUMENT\",\"inputs\":{\"documentId\":{\"literal\":\""+document+"\"}}}")); + UUID id=pending(publish(n),"{}");var token=claim("doc-worker"); + var context=new ActionContext(token.executionId(),token.requestId(),RequestCreationIT.owner,object("{}"),"metadata",1); + p.body=metadata(context,document).toString(); + var actions=p.actions(exampleCatalog(),p.route("DOCUMENTS_METADATA","",2000,2000,Map.of(),Optional.empty(),"")); + admin.execute("CREATE FUNCTION "+schema+".fail_doc_insert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'test document failure'; END; $$"); + admin.execute("CREATE TRIGGER test_doc_insert BEFORE INSERT ON "+schema+".document_reference FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_doc_insert()"); + try{assertThrows(org.springframework.dao.DataAccessException.class,()->engine(actions).run(token));} + finally{admin.execute("DROP TRIGGER test_doc_insert ON "+schema+".document_reference");} + assertEquals(0,admin.queryForObject("SELECT count(*) FROM "+schema+".document_reference WHERE id=?",Integer.class,document)); + assertEquals("RUNNING",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + expire(token);var recovered=claim("recovered");engine(actions).run(recovered); + assertEquals("SUCCEEDED",status(id));assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".document_reference WHERE id=?",Integer.class,document)); + assertEquals("SUCCEEDED",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + assertEquals(2,p.calls.get()); // metadata is READ_ONLY, same external key + } + } +} diff --git a/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java b/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java new file mode 100644 index 0000000..f40b3b7 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/HttpIntegrationTest.java @@ -0,0 +1,128 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.integration.infrastructure.*; +import com.organizacion.orchestrator.shared.domain.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.net.URI; +import java.util.*; +import static com.organizacion.orchestrator.HttpProviderFixture.*; +import static org.junit.jupiter.api.Assertions.*; + +class HttpIntegrationTest { + @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"))); + context.registerBean(com.organizacion.orchestrator.flow.infrastructure.FlowJsonCodec.class); + context.registerBean(io.micrometer.core.instrument.MeterRegistry.class,io.micrometer.core.instrument.simple.SimpleMeterRegistry::new); + context.register(IntegrationConfiguration.class,com.organizacion.orchestrator.flow.infrastructure.PublicationCatalogConfiguration.class); + context.refresh();assertInstanceOf(RegisteredWorkerActions.class,context.getBean(com.organizacion.orchestrator.execution.application.port.WorkerActions.class)); + assertTrue(context.getBean(HttpIntegrationHandler.class).capability(action()).isEmpty()); + } + } + @Test void documentInvalidIsBusinessOutcomeAndBureauScoreMustBeNumeric()throws Exception{ + try(var p=new HttpProviderFixture()){ + var catalog=exampleCatalog();var c=context(); + var document=new ActionDefinition.Integration("doc","doc","DOCUMENTO_VALIDA",Optional.of("1"),Optional.of("LICENCIA_CONDUCIR"),Optional.of("1"),Map.of(),RetryPolicy.DEFAULT); + var input=new ActionContext(c.executionId(),c.requestId(),c.actor(),object("{\"documentId\":\""+UUID.randomUUID()+"\"}"),c.externalIdempotencyKey(),1); + var h=p.handler(catalog,p.route("DOCUMENTO_VALIDA","LICENCIA_CONDUCIR",2000,2000,Map.of(),Optional.empty(),"")); + p.body="{\"status\":\"INVALID\",\"reasonCodes\":[\"EXPIRED\"]}"; + assertInstanceOf(ActionResult.Completed.class,h.execute(document,input)); + var bureau=new ActionDefinition.Integration("buro","buro","BURO_CONSULTA",Optional.of("1"),Optional.empty(),Optional.empty(),Map.of(),RetryPolicy.DEFAULT); + var bureauInput=new ActionContext(c.executionId(),c.requestId(),c.actor(),object("{\"client\":\"C1\",\"consentimientoId\":\"synthetic-consent\"}"),c.externalIdempotencyKey(),1); + h=p.handler(catalog,p.route("BURO_CONSULTA","",2000,2000,Map.of(),Optional.empty(),"")); + p.body="{\"score\":71,\"consultationId\":\"synthetic-id\"}";assertInstanceOf(ActionResult.Completed.class,h.execute(bureau,bureauInput)); + p.body="{\"score\":\"71\",\"consultationId\":\"synthetic-id\"}";assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(bureau,bureauInput)); + } + } + @Test void mapsExactDecimalAndUsesTrustedHeaders()throws Exception{ + try(var p=new HttpProviderFixture()){ + p.body="{\"data\":{\"amount\":71.1234567890123456789}}"; + var h=p.handler(FlowFixtures.catalog(),p.route("LOOKUP","BASIC",2000,2000,Map.of("score","/data/amount"),Optional.empty(),"PROVIDER_TOKEN")); + var result=assertInstanceOf(ActionResult.Completed.class,h.execute(action(),context())); + assertEquals(object("{\"score\":71.1234567890123456789}"),result.output()); + assertEquals("stable-external-key",p.key);assertEquals("Bearer test-token",p.authorization);assertEquals("1",p.profile); + assertEquals(object("{\"client\":\"C1\"}"),object(p.receivedBody)); + } + } + @ParameterizedTest @ValueSource(strings={"{\"score\":\"70\"}","{}","{\"score\":1,\"score\":2}","not json","[]"}) + void rejectsMalformedOrWrongContract(String body)throws Exception{ + try(var p=new HttpProviderFixture()){p.body=body;var r=assertInstanceOf(ActionResult.TechnicalFailure.class,p.handler().execute(action(),context()));assertEquals(ErrorCode.INTEGRATION_CONTRACT_ERROR,r.code());} + } + @Test void timeoutCoversBodyAfterHeaders()throws Exception{ + try(var p=new HttpProviderFixture()){ + p.delayMs=1000;var h=p.handler(FlowFixtures.catalog(),p.route("LOOKUP","BASIC",200,2000,Map.of(),Optional.empty(),"")); + long start=System.nanoTime();var r=assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(action(),context())); + assertEquals(ActionResult.FailureKind.TRANSIENT,r.kind());assertTrue(System.nanoTime()-start<900_000_000L); + } + } + @Test void boundsBodyAndRejectsUnexpectedRepresentation()throws Exception{ + try(var p=new HttpProviderFixture()){ + var h=p.handler(FlowFixtures.catalog(),p.route("LOOKUP","BASIC",2000,8,Map.of(),Optional.empty(),"")); + assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(action(),context())); + p.contentType="text/html";assertInstanceOf(ActionResult.TechnicalFailure.class,p.handler().execute(action(),context())); + } + } + @ParameterizedTest @ValueSource(ints={404,429,500,502,503,504,401,302}) + void httpErrorsAreNeverBusinessResults(int status)throws Exception{ + try(var p=new HttpProviderFixture()){ + p.status=status;var r=assertInstanceOf(ActionResult.TechnicalFailure.class,p.handler().execute(action(),context())); + assertEquals(status==429||status>=500?ActionResult.FailureKind.TRANSIENT:ActionResult.FailureKind.PERMANENT,r.kind()); + assertEquals(1,p.calls.get()); // redirects are not followed + } + } + @Test void onlyExplicit404MappingCanProduceNegativeBusinessResult()throws Exception{ + try(var p=new HttpProviderFixture()){ + p.status=404;var negative=object("{\"score\":0}"); + var h=p.handler(FlowFixtures.catalog(),p.route("LOOKUP","BASIC",2000,2000,Map.of(),Optional.of(negative),"")); + assertEquals(negative,assertInstanceOf(ActionResult.Completed.class,h.execute(action(),context())).output()); + } + } + @Test void refusesUnpinnedUnknownVersionsAndBadInputWithoutSending()throws Exception{ + try(var p=new HttpProviderFixture()){ + var a=action();var h=p.handler(); + for(var version:List.of(Optional.empty(),Optional.of("99"))){ + var invalid=new ActionDefinition.Integration(a.id(),a.resultKey(),a.integrationKey(),version,a.profile(),a.profileVersion(),a.inputs(),a.retry()); + assertTrue(h.capability(invalid).isEmpty());assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(invalid,context())); + } + var c=context();assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(a,new ActionContext(c.executionId(),c.requestId(),c.actor(),object("{\"url\":\"http://other\"}"),c.externalIdempotencyKey(),1))); + assertEquals(0,p.calls.get()); + } + } + @Test void missingSecretDoesNotSendAndNoOpIsSimulationOnly()throws Exception{ + try(var p=new HttpProviderFixture()){ + var cat=FlowFixtures.catalog();var routes=new HttpRoutes(List.of(p.route("LOOKUP","BASIC",2000,2000,Map.of(),Optional.empty(),"PROVIDER_TOKEN")),cat,true,60); + var h=new HttpIntegrationHandler(cat,routes,p.http,FlowFixtures.CODEC,name->null); + assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(action(),context()));assertEquals(0,p.calls.get()); + var noOp=new ActionDefinition.NoOp("noop","noop",object("{}"));var registry=new RegisteredWorkerActions(h,new RegisterDocumentHandler(cat,h)); + assertTrue(registry.capability(noOp).isEmpty());assertInstanceOf(ActionResult.TechnicalFailure.class,registry.execute(noOp,context())); + assertInstanceOf(ActionResult.Completed.class,new NoOpSimulationHandler().execute(noOp,context())); + } + } + @Test void unsafeWritesBecomeUnknownOnTimeoutOrInvalidSuccess()throws Exception{ + try(var p=new HttpProviderFixture()){ + var original=FlowFixtures.catalog();var c=original.integrations().get("LOOKUP").contracts().get("1"); + var write=new PublicationCatalog.Contract(c.input(),c.output(),ActionCapability.Effect.NON_IDEMPOTENT_WRITE,c.profiles(),c.defaultProfiles()); + var cat=new PublicationCatalog(original.roles(),original.handlers(),Map.of("LOOKUP",new PublicationCatalog.Integration("1",Map.of("1",write))),original.documents()); + var h=p.handler(cat,p.route("LOOKUP","BASIC",200,2000,Map.of(),Optional.empty(),"")); + p.body="{}";assertEquals(ActionResult.FailureKind.UNKNOWN_EFFECT,assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(action(),context())).kind()); + p.delayMs=1000;assertEquals(ActionResult.FailureKind.UNKNOWN_EFFECT,assertInstanceOf(ActionResult.TechnicalFailure.class,h.execute(action(),context())).kind()); + } + } + @Test void rejectsUnsafeRoutesDuplicatesAndLeaseExceedingBudget()throws Exception{ + try(var p=new HttpProviderFixture()){ + var r=p.route("LOOKUP","BASIC",2000,2000,Map.of(),Optional.empty(),"");var cat=FlowFixtures.catalog(); + assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(r),cat,false,60)); + assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(r,r),cat,true,60)); + assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(r),cat,true,1)); + for(String url:List.of("http://example.org/api","https://user:pass@example.org/api","https://example.org/api?secret=x")){ + var bad=new HttpRoutes.Route(r.key(),URI.create(url),"",100,2000,2000,Map.of(),Optional.empty()); + assertThrows(IllegalArgumentException.class,()->new HttpRoutes(List.of(bad),cat,true,60)); + } + } + } +} diff --git a/src/test/java/com/organizacion/orchestrator/HttpProviderFixture.java b/src/test/java/com/organizacion/orchestrator/HttpProviderFixture.java new file mode 100644 index 0000000..1bb84a0 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/HttpProviderFixture.java @@ -0,0 +1,67 @@ +package com.organizacion.orchestrator; + +import com.sun.net.httpserver.*; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.flow.domain.*; +import com.organizacion.orchestrator.integration.infrastructure.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** Actual loopback HTTP, entirely synthetic data. No provider or personal documents. */ +final class HttpProviderFixture implements AutoCloseable { + final HttpServer server; + final ExecutorService executor=Executors.newVirtualThreadPerTaskExecutor(); + final BoundedHttp http=new BoundedHttp(); + final AtomicInteger calls=new AtomicInteger(); + final Set effectKeys=ConcurrentHashMap.newKeySet(); + volatile String retryAfter; + volatile int status=200,delayMs=0; + volatile String body="{\"score\":70}",contentType="application/json",receivedBody,key,authorization,profile; + HttpProviderFixture()throws Exception{ + server=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0); + server.setExecutor(executor); + server.createContext("/",exchange->{ + calls.incrementAndGet();receivedBody=new String(exchange.getRequestBody().readAllBytes(),StandardCharsets.UTF_8); + key=exchange.getRequestHeaders().getFirst("Idempotency-Key"); + if(key!=null)effectKeys.add(key); + authorization=exchange.getRequestHeaders().getFirst("Authorization"); + profile=exchange.getRequestHeaders().getFirst("X-Profile-Version"); + try{ + exchange.getResponseHeaders().set("Content-Type",contentType); + if(retryAfter!=null)exchange.getResponseHeaders().set("Retry-After",retryAfter); + if(status==302)exchange.getResponseHeaders().set("Location",uri().toString()); + byte[] bytes=body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status,bytes.length); + if(delayMs>0)Thread.sleep(delayMs); // headers arrive promptly; body must still obey deadline + exchange.getResponseBody().write(bytes); + }catch(InterruptedException e){Thread.currentThread().interrupt();} + finally{exchange.close();} + });server.start(); + } + URI uri(){return URI.create("http://127.0.0.1:"+server.getAddress().getPort()+"/provider");} + static ObjectValue object(String value){return (ObjectValue)FlowFixtures.CODEC.value(new com.organizacion.orchestrator.request.infrastructure.RequestJson(FlowFixtures.CODEC).parse(value));} + static ActionDefinition.Integration action(){return new ActionDefinition.Integration("lookup","lookup","LOOKUP",Optional.of("1"),Optional.of("BASIC"),Optional.of("1"),Map.of(),RetryPolicy.DEFAULT);} + static ActionContext context(){return new ActionContext(new ExecutionId(UUID.randomUUID()),new RequestId(UUID.randomUUID()),new Actor("owner","scope-a",Set.of("ANALISTA")),object("{\"client\":\"C1\"}"),"stable-external-key",1);} + HttpRoutes.Route route(String integration,String profile,int timeout,int max,Map mappings,Optional negative,String secret){ + return new HttpRoutes.Route(new HttpRoutes.Key(integration,"1",profile,profile.isEmpty()?"":"1"),uri(),secret,Math.min(100,timeout),timeout,max,mappings,negative); + } + HttpIntegrationHandler handler(PublicationCatalog catalog,HttpRoutes.Route route){ + return new HttpIntegrationHandler(catalog,new HttpRoutes(List.of(route),catalog,true,60),http,FlowFixtures.CODEC,name->"test-token"); + } + HttpIntegrationHandler handler(){return handler(FlowFixtures.catalog(),route("LOOKUP","BASIC",2000,262144,Map.of(),Optional.empty(),""));} + static PublicationCatalog exampleCatalog(){return new com.organizacion.orchestrator.flow.infrastructure.PublicationCatalogLoader(FlowFixtures.CODEC).load(java.nio.file.Path.of("examples/integrations/publication-catalog.json"));} + static com.fasterxml.jackson.databind.node.ObjectNode metadata(ActionContext c,UUID document){ + var n=FlowFixtures.JSON.createObjectNode();n.put("documentId",document.toString()).put("requestId",c.requestId().value().toString()) + .put("businessScope",c.actor().businessScope()).put("documentType","DOCUMENT").put("storageDocumentId","synthetic-storage-id") + .put("mimeType","application/pdf").put("sizeBytes",100).put("checksum","a".repeat(64)).put("scanStatus","CLEAN");return n; + } + RegisteredWorkerActions actions(PublicationCatalog catalog,HttpRoutes.Route route){var integration=handler(catalog,route);return new RegisteredWorkerActions(integration,new RegisterDocumentHandler(catalog,integration));} + public void close(){http.close();server.stop(0);executor.shutdownNow();} +} diff --git a/src/test/java/com/organizacion/orchestrator/IntervalOverlapAnalyzerTest.java b/src/test/java/com/organizacion/orchestrator/IntervalOverlapAnalyzerTest.java new file mode 100644 index 0000000..a15151c --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/IntervalOverlapAnalyzerTest.java @@ -0,0 +1,56 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.NumberValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import java.math.BigDecimal; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class IntervalOverlapAnalyzerTest { + private final IntervalOverlapAnalyzer analyzer = new IntervalOverlapAnalyzer(); + private static ValueExpression.Path path(String path) { return new ValueExpression.Path(path); } + private static ValueExpression.Literal n(String n) { return new ValueExpression.Literal(new NumberValue(new BigDecimal(n))); } + private static Condition bound(Condition.Comparison op, String n) { return new Condition.Compare(op, path("/results/score"), n(n)); } + private static DecisionDefinition decision(Condition... conditions) { + var rules = new ArrayList(); + for (int i = 0; i < conditions.length; i++) rules.add(new DecisionDefinition.Rule("rule" + i, conditions[i], "OUT")); + return new DecisionDefinition("d", DecisionDefinition.HitPolicy.UNIQUE, rules, "DEFAULT"); + } + @ParameterizedTest + @CsvSource({"GT,LTE,false", "GTE,LTE,true", "GT,LT,false", "GTE,LT,false", "EQ,EQ,true", "EQ,GT,false", "EQ,GTE,true"}) + void sharedBoundaryRequiresBothSidesInclusive(Condition.Comparison first, Condition.Comparison second, boolean overlaps) { + var result = analyzer.analyze(decision(bound(first, "70"), bound(second, "70.00"))); + assertEquals(overlaps ? 1 : 0, result.overlaps().size()); + assertTrue(result.unsupportedRuleIds().isEmpty()); + } + @Test + void conjunctiveIntervalsHandleScoringEmptyRangesAndReversedOperands() { + var medium = new Condition.Logical(Condition.Junction.AND, + List.of(bound(Condition.Comparison.GT, "60"), bound(Condition.Comparison.LTE, "70"))); + assertTrue(analyzer.analyze(decision(bound(Condition.Comparison.GT, "70"), medium)).overlaps().isEmpty()); + var overlap = analyzer.analyze(decision(bound(Condition.Comparison.GTE, "70"), medium)).overlaps().getFirst(); + assertEquals("rule0", overlap.firstRuleId()); assertEquals("rule1", overlap.secondRuleId()); + assertEquals("/results/score", overlap.path()); + var empty = new Condition.Logical(Condition.Junction.AND, + List.of(bound(Condition.Comparison.GT, "70"), bound(Condition.Comparison.LTE, "60"))); + assertTrue(analyzer.analyze(decision(empty, medium)).overlaps().isEmpty()); + var reversed = new Condition.Compare(Condition.Comparison.LT, n("60"), path("/results/score")); + assertEquals(1, analyzer.analyze(decision(reversed, medium)).overlaps().size()); + } + @Test + void unsupportedLogicIsReportedRatherThanCertifiedDisjoint() { + var exists = new Condition.Exists(path("/results/score")); + var union = new Condition.Logical(Condition.Junction.OR, + List.of(bound(Condition.Comparison.GT, "70"), bound(Condition.Comparison.LT, "60"))); + var mixedPaths = new Condition.Logical(Condition.Junction.AND, + List.of(bound(Condition.Comparison.GT, "70"), + new Condition.Compare(Condition.Comparison.GT, path("/results/other"), n("60")))); + var result = analyzer.analyze(decision(exists, union, mixedPaths)); + assertEquals(List.of("rule0", "rule1", "rule2"), result.unsupportedRuleIds()); + assertTrue(result.overlaps().isEmpty()); + assertThrows(UnsupportedOperationException.class, () -> result.unsupportedRuleIds().clear()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/JsonPointerResolverTest.java b/src/test/java/com/organizacion/orchestrator/JsonPointerResolverTest.java new file mode 100644 index 0000000..127f971 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/JsonPointerResolverTest.java @@ -0,0 +1,43 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.decision.domain.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class JsonPointerResolverTest { + private final JsonPointerResolver resolver = new JsonPointerResolver(); + private static final StringValue VALUE = new StringValue("value"); + private final EvaluationContext ctx = new EvaluationContext( + new ObjectValue(Map.of("x", VALUE)), new ObjectValue(Map.of("roles", new StringValue("UNTRUSTED"))), + new ObjectValue(Map.of("a/b", VALUE, "a~b", VALUE, "~1", VALUE, "", VALUE, + "list", new ArrayValue(List.of(VALUE, NullValue.INSTANCE)), "scalar", VALUE)), + new Actor("verified", "scope", Set.of("Z_ROLE", "A_ROLE"))); + + @ParameterizedTest + @ValueSource(strings = {"/request/data/x", "/results/a~1b", "/results/a~0b", "/results/~01", "/results/", "/results/list/0"}) + void resolvesEscapesObjectKeysAndArrayElements(String pointer) { + assertEquals(new Lookup.Present(VALUE), resolver.resolve(new ValueExpression.Path(pointer), ctx)); + } + @ParameterizedTest + @ValueSource(strings = {"/results/missing", "/results/list/2", "/results/list/-", "/results/list/01", + "/results/list/-1", "/results/list/9999999999999999999999999999", "/results/list/2147483648", + "/results/scalar/child", "/results/list/1/child", "/results/list/1/"}) + void unresolvedPointersReturnMissingWithoutCoercion(String pointer) { + assertEquals(Lookup.Missing.INSTANCE, resolver.resolve(new ValueExpression.Path(pointer), ctx)); + } + @Test + void rootsNullAndActorAttributesAreDistinctAndTrusted() { + assertEquals(new Lookup.Present(NullValue.INSTANCE), resolver.resolve(new ValueExpression.Path("/results/list/1"), ctx)); + assertEquals(new Lookup.Present(ctx.requestData()), resolver.resolve(new ValueExpression.Path("/request/data"), ctx)); + assertEquals(new Lookup.Present(ctx.eventData()), resolver.resolve(new ValueExpression.Path("/event/data"), ctx)); + assertEquals(new Lookup.Present(ctx.results()), resolver.resolve(new ValueExpression.Path("/results"), ctx)); + assertEquals(new Lookup.Present(new StringValue("verified")), resolver.resolve(new ValueExpression.Path("/actor/subject"), ctx)); + assertEquals(new Lookup.Present(new StringValue("scope")), resolver.resolve(new ValueExpression.Path("/actor/businessScope"), ctx)); + assertEquals(new Lookup.Present(new StringValue("A_ROLE")), resolver.resolve(new ValueExpression.Path("/actor/roles/0"), ctx)); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/ObservabilityIT.java b/src/test/java/com/organizacion/orchestrator/ObservabilityIT.java new file mode 100644 index 0000000..b40596e --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/ObservabilityIT.java @@ -0,0 +1,112 @@ +package com.organizacion.orchestrator; + +import com.fasterxml.jackson.databind.JsonNode; +import com.organizacion.orchestrator.shared.infrastructure.observability.QueueMetrics; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ObservabilityIT { + @BeforeAll static void setup()throws Exception{RequestCreationIT.setup();WorkerIT.store=fresh();} + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @BeforeEach void isolate(){new WorkerIT().isolateQueue();} + static UUID create()throws Exception{return AvailableEventsIT.create(flow(false),"{}");} + static JsonNode history(UUID request,String query)throws Exception{ + var r=call("GET","/api/v1/requests/"+request+"/history"+query,RequestCreationIT.jwt(),null,null); + assertEquals(200,r.statusCode(),r.body());RequestCreationIT.contract(r,"HistoryPage");assertEquals("no-store",r.headers().firstValue("Cache-Control").orElseThrow()); + return json.readTree(r.body()); + } + static String enc(String s){return URLEncoder.encode(s,StandardCharsets.UTF_8);} + @Test void historyPaginatesWithStableTieBreakAndFilters()throws Exception { + UUID request=create();var accepted=EventAcceptanceIT.event(request,unique(),0);assertEquals(202,accepted.statusCode()); + UUID execution=UUID.fromString(json.readTree(accepted.body()).get("executionId").asText());engine(none()).run(claim("history")); + // Same timestamp exercises UUID tie-break, rather than depending on wall-clock spacing. + for(int i=0;i<3;i++)admin.update("INSERT INTO "+schema+".audit_entry(id,request_id,kind,actor_subject,flow_id,detail,created_at) SELECT ?,id,'TIE','owner',flow_id,'{}'::jsonb,'2026-01-01T00:00:00Z' FROM "+schema+".request_instance WHERE id=?",UUID.randomUUID(),request); + assertThrows(org.springframework.dao.DataAccessException.class,()->admin.update("UPDATE "+schema+".audit_entry SET kind='CHANGED' WHERE request_id=?",request)); + var all=history(request,"?kind=TIE");assertEquals(3,all.get("items").size());var ids=new ArrayList();String query="?kind=TIE&limit=1"; + for(int page=0;page<3;page++){ + var n=history(request,query);assertEquals(1,n.get("items").size());ids.add(n.at("/items/0/id").asText()); + if(page<2){assertTrue(n.has("nextCursor"));query="?kind=TIE&limit=1&cursor="+enc(n.get("nextCursor").asText());}else assertFalse(n.has("nextCursor")); + } + assertEquals(3,new HashSet<>(ids).size());assertEquals(all.get("items").findValuesAsText("id"),ids); + assertEquals(1,history(request,"?kind=TRANSITION_COMPLETED").get("items").size()); + assertEquals(2,history(request,"?executionId="+execution).get("items").size()); + assertEquals(0,history(request,"?executionId="+UUID.randomUUID()).get("items").size()); + assertEquals(3,history(request,"?from=2026-01-01T00:00:00Z&to=2026-01-02T00:00:00Z").get("items").size()); + assertEquals(0,history(request,"?to=2026-01-01T00:00:00Z").get("items").size()); + } + @Test void historyAuthorizationAndCursorBindingNeverExposeForeignResource()throws Exception { + UUID request=create(),other=create();assertEquals(202,EventAcceptanceIT.event(request,unique(),0).statusCode()); + var cursor=history(request,"?limit=1").get("nextCursor").asText();String path="/api/v1/requests/"+request+"/history"; + assertEquals(401,call("GET",path,null,null,null).statusCode()); + for(String jwt:List.of(token("ANALISTA","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner"))) + assertEquals(404,call("GET",path,jwt,null,null).statusCode()); + assertEquals(400,call("GET",path+"?kind=EVENT_ACCEPTED&cursor="+enc(cursor),RequestCreationIT.jwt(),null,null).statusCode()); + assertEquals(400,call("GET",path+"?kind=null&cursor="+enc(cursor),RequestCreationIT.jwt(),null,null).statusCode()); + assertEquals(400,call("GET","/api/v1/requests/"+other+"/history?cursor="+enc(cursor),RequestCreationIT.jwt(),null,null).statusCode()); + } + @ParameterizedTest @ValueSource(strings={"limit=0","limit=101","limit=x","kind=a%27OR1","from=tomorrow","from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z","from=2026-01-01T00:00:00%2B00:00","cursor=bad!","executionId=invalid"}) + void invalidHistoryQueryIs400(String query)throws Exception { + UUID request=create();assertEquals(400,call("GET","/api/v1/requests/"+request+"/history?"+query,RequestCreationIT.jwt(),null,null).statusCode()); + } + @Test void historyDoesNotProjectArbitraryDetailsOrEvidence()throws Exception { + UUID request=create();admin.update("INSERT INTO "+schema+".audit_entry(id,request_id,kind,actor_subject,flow_id,detail) SELECT ?,id,'TEST','owner',flow_id,?::jsonb FROM "+schema+".request_instance WHERE id=?",UUID.randomUUID(),"{\"ruleIds\":[\"approved\"],\"reasonCodes\":[\"EXPIRED\"],\"score\":999,\"token\":\"SENTINEL_SECRET\",\"evidence\":\"SENTINEL_PERSONAL\"}",request); + var n=history(request,"?kind=TEST");assertEquals("approved",n.at("/items/0/detail/ruleIds/0").asText());assertEquals("EXPIRED",n.at("/items/0/detail/reasonCodes/0").asText()); + assertFalse(n.toString().contains("SENTINEL"));assertFalse(n.toString().contains("score")); + } + @Test void commandAndWorkerLogsCarryReferencesWithoutClientOrScore()throws Exception { + var command=(ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(com.organizacion.orchestrator.shared.api.CommandObservationFilter.class); + var worker=(ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(com.organizacion.orchestrator.execution.infrastructure.WorkerEngine.class); + var appender=new ch.qos.logback.core.read.ListAppender();appender.start(); + var commandLevel=command.getLevel();var workerLevel=worker.getLevel();command.setLevel(ch.qos.logback.classic.Level.INFO);worker.setLevel(ch.qos.logback.classic.Level.INFO);command.addAppender(appender);worker.addAppender(appender); + try{ + UUID execution=pending(flow(true),"{\"client\":\"SENTINEL_PERSONAL_123\"}"); + engine(actions(com.organizacion.orchestrator.action.domain.ActionCapability.Effect.READ_ONLY,(a,c)->ok(987654321))).run(claim("logs")); + String text=appender.list.stream().map(ch.qos.logback.classic.spi.ILoggingEvent::getFormattedMessage).collect(java.util.stream.Collectors.joining("\n")); + assertTrue(text.contains("correlationId="));assertTrue(text.contains("executionId="+execution));assertTrue(text.contains("flowId="));assertTrue(text.contains("actionId=lookup"));assertTrue(text.contains("attempt=1")); + assertFalse(text.contains("SENTINEL"));assertFalse(text.contains("987654321"));assertFalse(text.contains("Bearer")); + }finally{command.detachAppender(appender);worker.detachAppender(appender);command.setLevel(commandLevel);worker.setLevel(workerLevel);appender.stop();} + } + @Test void metricsRequireOperatorAndScopeAndCountTransportRejections()throws Exception { + String operator=token("EXECUTION_OPERATOR","scope-a",""); + assertEquals(401,call("GET","/actuator/metrics",null,null,null).statusCode()); + assertEquals(403,call("GET","/actuator/metrics",RequestCreationIT.jwt(),null,null).statusCode()); + assertEquals(403,call("GET","/actuator/metrics",token("EXECUTION_OPERATOR","scope-b",""),null,null).statusCode()); + assertEquals(403,call("GET","/actuator/env",operator,null,null).statusCode()); + assertEquals(200,call("GET","/actuator/metrics",operator,null,null).statusCode()); + var registry=app.getBean(MeterRegistry.class);double before=registry.counter("orchestrator.commands","command","create","result","rejected").count(); + assertEquals(404,RequestCreationIT.post("MISSING",unique(),"{}",RequestCreationIT.jwt()).statusCode()); + assertEquals(before+1,registry.counter("orchestrator.commands","command","create","result","rejected").count()); + for(var meter:registry.getMeters())if(meter.getId().getName().startsWith("orchestrator.")) + assertTrue(meter.getId().getTags().stream().allMatch(t->Set.of("command","result","state","integration").contains(t.getKey()))); + } + @Test void queueMetricsReadDbTimeStatesOldestAndExpiredWithoutIds()throws Exception { + UUID first=pending(flow(false),"{}"),second=pending(flow(false),"{}");var token=claim("metrics");expire(token); + admin.update("UPDATE "+schema+".execution SET created_at=clock_timestamp()-interval '60 seconds' WHERE id=?",token.executionId().value().equals(first)?second:first); + var meters=new SimpleMeterRegistry();try{ + var sample=new QueueMetrics(runtime,schema,meters);assertEquals(1,sample.value("available"));assertEquals(1,sample.value("PENDING"));assertEquals(1,sample.value("RUNNING")); + assertEquals(1,sample.value("expired"));assertTrue(sample.value("oldest")>=60); + assertEquals(6,meters.find("orchestrator.executions").gauges().size()); + }finally{meters.close();} + } + @Test @Order(Integer.MAX_VALUE) void readinessDependsOnDatabaseAndLivenessDoesNot()throws Exception { + assertEquals(200,call("GET","/actuator/health/readiness",null,null,null).statusCode()); + assertEquals(200,call("GET","/actuator/health/liveness",null,null,null).statusCode()); + app.getBean(com.zaxxer.hikari.HikariDataSource.class).close(); + assertEquals(503,call("GET","/actuator/health/readiness",null,null,null).statusCode()); + var live=call("GET","/actuator/health/liveness",null,null,null);assertEquals(200,live.statusCode());assertFalse(live.body().contains("components")); + var meters=new SimpleMeterRegistry();try{ + var failed=new QueueMetrics(app.getBean(javax.sql.DataSource.class),schema,meters);assertEquals(0,failed.value("available"));assertTrue(Double.isNaN(failed.value("PENDING"))); + }finally{meters.close();} + } +} diff --git a/src/test/java/com/organizacion/orchestrator/ObservabilityTest.java b/src/test/java/com/organizacion/orchestrator/ObservabilityTest.java new file mode 100644 index 0000000..4e89004 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/ObservabilityTest.java @@ -0,0 +1,57 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.execution.application.port.*; +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.execution.infrastructure.*; +import com.organizacion.orchestrator.integration.infrastructure.*; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.*; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; + +class ObservabilityTest { + @Test void integrationTimersUseOnlyConfiguredKeysAndFiniteResults()throws Exception { + var meters=new SimpleMeterRegistry(); + try(var p=new HttpProviderFixture()){ + var catalog=FlowFixtures.catalog();var route=p.route("LOOKUP","BASIC",5000,8000,Map.of(),Optional.empty(),""); + var handler=new HttpIntegrationHandler(catalog,new HttpRoutes(List.of(route),catalog,true,60),p.http,FlowFixtures.CODEC,k->null,meters); + p.body="{\"score\":71}";handler.execute(HttpProviderFixture.action(),HttpProviderFixture.context()); + p.status=503;handler.execute(HttpProviderFixture.action(),HttpProviderFixture.context()); + assertEquals(1,meters.get("orchestrator.integration.duration").tags("integration","LOOKUP","result","completed").timer().count()); + assertEquals(1,meters.get("orchestrator.integration.duration").tags("integration","LOOKUP","result","failed").timer().count()); + assertTrue(meters.getMeters().stream().allMatch(m->m.getId().getTags().stream().allMatch(t->Set.of("integration","result").contains(t.getKey())))); + }finally{meters.close();} + } + @ParameterizedTest @ValueSource(booleans={false,true}) + void shutdownStopsClaimsAndEitherFinishesOrInterruptsWork(boolean force)throws Exception { + var entered=new CountDownLatch(1);var release=new CountDownLatch(1);var renewed=new CountDownLatch(1);var interrupted=new CountDownLatch(1);var completed=new AtomicInteger(); + var token=new Token(new ExecutionId(UUID.randomUUID()),new RequestId(UUID.randomUUID()),"test",1);var once=new AtomicBoolean();var claims=new AtomicInteger(); + WorkerStore store=(WorkerStore)java.lang.reflect.Proxy.newProxyInstance(WorkerStore.class.getClassLoader(),new Class[]{WorkerStore.class},(proxy,method,args)->{ + switch(method.getName()){ + case "claim":claims.incrementAndGet();return once.compareAndSet(false,true)?List.of(token):List.of(); + case "renew":renewed.countDown();return null; + case "complete":completed.incrementAndGet();return null; + case "load":entered.countDown();try{if(!release.await(8,TimeUnit.SECONDS))throw new IllegalStateException("test release timed out");}catch(InterruptedException e){interrupted.countDown();throw new IllegalStateException("interrupted");} + var flow=FlowFixtures.CODEC.decode(FlowFixtures.simple().toString());return new Work(token,new FlowId(UUID.randomUUID()),flow,flow.transitions().getFirst(),JsonValue.ObjectValue.empty(),JsonValue.ObjectValue.empty(),RequestCreationIT.owner,1); + default:throw new AssertionError("Unexpected worker call: "+method.getName()); + } + }); + var engine=new WorkerEngine(store,WorkerIT.none(),FlowFixtures.CODEC); + var loop=new WorkerLoop(store,engine,1,1,100,3,1,force?2:5); + try(var pool=Executors.newSingleThreadExecutor()){ + loop.start();assertTrue(entered.await(5,TimeUnit.SECONDS)); + var stopping=pool.submit(()->{loop.stop();}); + assertTrue(renewed.await(4,TimeUnit.SECONDS),"lease renews while draining"); + if(!force)release.countDown();stopping.get(8,TimeUnit.SECONDS);assertFalse(loop.isRunning());assertEquals(1,claims.get()); + if(force){assertEquals(0,interrupted.getCount());assertEquals(0,completed.get());} + else assertEquals(1,completed.get()); + loop.stop(); + }finally{release.countDown();loop.stop();} + } +} diff --git a/src/test/java/com/organizacion/orchestrator/README.md b/src/test/java/com/organizacion/orchestrator/README.md index b28ac37..b1d2754 100644 --- a/src/test/java/com/organizacion/orchestrator/README.md +++ b/src/test/java/com/organizacion/orchestrator/README.md @@ -11,3 +11,67 @@ T03 incorpora Testcontainers PostgreSQL; nunca sustituir concurrencia o JSONB po No omitir integraciones silenciosamente cuando Docker no esté disponible. Postgres96CompatibilityProbe (paquete com.organizacion.compatibility) se ejecuta únicamente con -Ppostgres96-check y variables DB/JWT externas. Arranca el checkout con PostgreSQL 9.6 en modo sólo lectura, valida dialecto/Flyway y salud. No cambia el comportamiento de migración del servicio normal ni sustituye T03. + +DatabaseMigrationIT ejecuta 40 casos en PostgreSQL real. Por defecto usa +Testcontainers postgres:9.6.24. Alternativa explícita: migration-tests-external y +MIGRATION_TEST_DB_URL/USER/PASSWORD (base orquestador_test obligatoria). +Ver RUNBOOK para aislamiento, permisos y limpieza. La prueba de fallo V999 genera +un ERROR esperado de Flyway y comprueba rollback de DDL e historial. + +T04 añade DomainModelTest (inmutabilidad, contratos e invariantes), +DomainArchitectureTest (recompilación aislada con java.base) y DomainBoundaryTest +(DTO separado validado contra el schema REST). Se ejecutan con `./mvnw test`, sin +base de datos ni Spring context. No acreditan el evaluador T05 ni servicios futuros. + +T05 añade DecisionEvaluatorTest, JsonPointerResolverTest e IntervalOverlapAnalyzerTest. +Cubren scoring contractual, tipos/null/ausencia, los once operadores, políticas, +límites, reuso concurrente y solapamientos. DomainArchitectureTest incluye también +la implementación pura del puerto. Se ejecutan en test sin DB ni proveedores. + +T06 añade FlowValidationTest y FlowPublicationIT. La primera valida DSL, catálogo, +mappings, grafo, tareas y canonicalización; la segunda usa PostgreSQL real y verifica +revisiones, inmutabilidad, auditoría/rollback y carreras administrativas/asignación. +No representa una prueba HTTP: las rutas administrativas esperan la seguridad T07. + +T07 añade SecurityPolicyTest y SecurityHttpIT con JWT RSA/JWKS local, HTTP real, +rol PostgreSQL temporal restringido y recursos persistidos. ResourceProbe es solo +de test y no se empaqueta en el JAR. + +T08 añade RequestJsonTest y RequestCreationIT: POST/GET reales, contrato DTO, +concurrencia/idempotencia, versión fijada, retiro y rollback SQL en dos fronteras. +Reutiliza el fixture DB/JWKS T07 con REQUEST_API_ENABLED=true y administración +apagada; esquema/rol temporal se eliminan al finalizar cada suite. El perfil +migration-tests-external solo admite orquestador_test y requiere las variables +MIGRATION_TEST_DB_*. No prueba Keycloak real ni hace despliegue. + +T09: EventAcceptanceIT prueba HTTP/JWT/SQL reales para aceptación PENDING, A07–A09, +replay, roles/propiedad/guardias, flujo retirado y rollback en ejecución/revisión/audit. +Reutiliza fixture T07/T08; las modificaciones directas de status son fixtures, no +un worker ni integración externa. DomainArchitectureTest incluye execution/application. + +T10: WorkerIT usa PostgreSQL, JWT y HTTP reales; adapters y proveedor idempotente +son dobles exclusivamente de test. Verifica SKIP LOCKED multiinstancia, fencing, +leases, checkpoints, finalización, rollback y consulta autorizada. Una caída simulada +interrumpe el intento antes del checkpoint; no se anuncia reinicio real de JVM. +Las pruebas de migración ahora esperan V1/V2/V3. Consultar WORKER.md. + +T11: HttpIntegrationTest y RegisterDocumentTest usan servidores HTTP loopback, +datos sintéticos y contratos de examples/integrations; no requieren PostgreSQL. +HttpIntegrationIT recorre API/worker/HTTP/PostgreSQL, conserva resultados negativos +como negocio y verifica rollback de referencia documental/checkpoint. Solo usa +orquestador_test y limpia schema/rol propios. INTEGRATIONS.md y ADR-015 detallan +los límites: sin proveedor real; T12 agrega retries y conciliación. + +T12: RetryPolicyTest verifica límites/backoff/Retry-After; RetryReconciliationIT +prueba reintentos SQL, checkpoints, autorización/evidencia/replay/concurrencia de +resoluciones y rollback inducido. CrashWorkerProcess es una JVM hija exclusiva de +prueba terminada antes/después de HTTP o con un checkpoint previo confirmado. +El padre recupera contra el mismo PostgreSQL; el proveedor sigue siendo mock. + +T13: AvailableEventPolicyTest cubre selección pura y compatibilidad del fixture. +AvailableEventsIT verifica snapshot de SQL, versiones fijadas/retiradas, JWT/roles, +guardias diferidas y los cuatro bloqueos activos. DocumentTasksIT prueba el recorrido +documental HTTP/DB: INVALID conserva tarea/razón, VALID avanza, metadatos ajenos o +fuera de política no crean referencias y fallos técnicos no producen INVALID. +También rechaza score/validación/metadatos del proveedor inyectados por el cliente. +No se construye interfaz gráfica ni se usan documentos reales. diff --git a/src/test/java/com/organizacion/orchestrator/RegisterDocumentTest.java b/src/test/java/com/organizacion/orchestrator/RegisterDocumentTest.java new file mode 100644 index 0000000..a2e354c --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/RegisterDocumentTest.java @@ -0,0 +1,43 @@ +package com.organizacion.orchestrator; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.infrastructure.*; +import com.organizacion.orchestrator.decision.domain.ValueExpression; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.integration.infrastructure.HttpRoutes; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.HttpProviderFixture.*; + +class RegisterDocumentTest { + @ParameterizedTest @ValueSource(strings={"valid","requestId","businessScope","documentId","documentType","mimeType","sizeBytes","checksum","scanStatus"}) + void verifiesMetadataBeforeReturningReference(String mutation)throws Exception{ + try(var p=new HttpProviderFixture()){ + var base=context();var id=UUID.randomUUID();var c=new ActionContext(base.executionId(),base.requestId(),base.actor(),object("{\"documentId\":\""+id+"\"}"),base.externalIdempotencyKey(),1); + var body=metadata(c,id); + switch(mutation){ + case "requestId","documentId"->body.put(mutation,UUID.randomUUID().toString()); + case "sizeBytes"->body.put(mutation,1001); + case "scanStatus"->body.put(mutation,"INFECTED"); + case "valid"->{} + default->body.put(mutation,"wrong"); + } + p.body=body.toString();var catalog=exampleCatalog(); + var h=new RegisterDocumentHandler(catalog,p.handler(catalog,p.route("DOCUMENTS_METADATA","",2000,2000,Map.of(),Optional.empty(),""))); + var definition=new ActionDefinition.RegisterDocument("doc","doc","DOCUMENT",new ValueExpression.Literal(new JsonValue.StringValue(id.toString())),RetryPolicy.DEFAULT); + var result=h.execute(definition,c); + if(mutation.equals("valid")){ + var output=assertInstanceOf(ActionResult.Completed.class,result).output(); + assertFalse(output.fields().containsKey("scanStatus"));assertEquals(8,output.fields().size()); + assertTrue(p.receivedBody.contains(c.requestId().value().toString())); + }else assertInstanceOf(ActionResult.TechnicalFailure.class,result); + } + } + @Test void deploymentExamplesLoadAndPinAllRoutes(){ + var catalog=exampleCatalog(); + var routes=HttpRoutes.load(java.nio.file.Path.of("examples/integrations/integration-routes.example.json"),catalog,FlowFixtures.CODEC,false,60); + assertTrue(routes.find(new HttpRoutes.Key("DOCUMENTO_VALIDA","1","LICENCIA_CONDUCIR","1")).isPresent()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/RequestCreationIT.java b/src/test/java/com/organizacion/orchestrator/RequestCreationIT.java new file mode 100644 index 0000000..a086086 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/RequestCreationIT.java @@ -0,0 +1,167 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.flow.application.FlowAdministration; +import com.organizacion.orchestrator.flow.application.port.FlowStore; +import com.organizacion.orchestrator.flow.domain.FlowVersion; +import com.organizacion.orchestrator.flow.infrastructure.*; +import com.organizacion.orchestrator.request.application.Requests; +import com.organizacion.orchestrator.request.infrastructure.PostgresRequestStore; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.ObjectValue; +import com.networknt.schema.*; +import org.junit.jupiter.api.*; +import java.net.URI; +import java.net.http.*; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Function; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Reuses only the isolated database/JWKS fixture; business API runs with admin disabled. */ +class RequestCreationIT { + static FlowAdministration flows;static FlowStore flowStore; + static final Actor manager=new Actor("manager","scope-a",Set.of("FLOW_EDITOR","FLOW_PUBLISHER","FLOW_ADMIN")); + static final Actor owner=new Actor("owner","scope-a",Set.of("ANALISTA")); + @BeforeAll static void setup()throws Exception{setup(false);} + static void setup(boolean administration,String... extra)throws Exception{ + SecurityHttpIT.start(administration,true,extra);flowStore=app.getBean(FlowStore.class); + flows=new FlowAdministration(flowStore,new CatalogFlowPreparation(FlowFixtures.CODEC,FlowFixtures.validator())); + admin.execute("CREATE FUNCTION "+schema+".fail_request_write() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN " + +"IF (TG_TABLE_NAME='audit_entry' AND NEW.detail->>'correlationId'='fail-audit') THEN RAISE EXCEPTION 'test audit failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_fail_audit BEFORE INSERT ON "+schema+".audit_entry FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_request_write()"); + admin.execute("CREATE FUNCTION "+schema+".fail_ledger_write() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN " + +"IF NEW.idempotency_key='fail-ledger' THEN RAISE EXCEPTION 'test ledger failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_fail_ledger BEFORE INSERT ON "+schema+".request_creation_idempotency FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_ledger_write()"); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + static String unique(){return "T_"+UUID.randomUUID().toString().replace("-","");} + static FlowVersion publish(int version){ + var n=FlowFixtures.simple();n.put("flowKey",unique()).put("version",version); + var f=flows.create(n.toString(),manager,"create");return flows.publish(f.id(),0,manager,"publish"); + } + static String type(FlowVersion f){String type=unique();admin.update("INSERT INTO "+schema+".request_type(type_key) VALUES (?)",type);flows.activate(type,f.id(),0,manager,"activate");return type;} + static HttpResponse post(String type,String key,String data,String token)throws Exception{return postBody("{\"typeKey\":\""+type+"\",\"data\":"+data+"}",key,token);} + static HttpResponse postBody(String body,String key,String token)throws Exception{ + var b=HttpRequest.newBuilder(URI.create(base+"/api/v1/requests")).timeout(java.time.Duration.ofSeconds(25)).header("Content-Type","application/json"); + if(key!=null)b.header("Idempotency-Key",key);if(token!=null)b.header("Authorization","Bearer "+token); + return http.send(b.POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + } + static String jwt()throws Exception{return token("ANALISTA","scope-a","");} + static void contract(HttpResponse response,String definition)throws Exception{ + var validator=JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(SchemaLocation.of(Path.of("schemas/api.schema.json").toAbsolutePath().toUri()+"#/$defs/"+definition)); + assertEquals(Set.of(),validator.validate(json.readTree(response.body())),response.body()); + } + @Test void createGetReplayPinningAndAdminDisabled()throws Exception{ + var first=publish(1);String type=type(first),key=unique(),jwt=jwt(); + var created=post(type,key,"{\"client\":\"C1\"}",jwt);assertEquals(201,created.statusCode(),created.body());contract(created,"RequestView"); + String location=created.headers().firstValue("Location").orElseThrow();assertEquals("\"0\"",created.headers().firstValue("ETag").orElseThrow()); + assertEquals(created.body(),call("GET",location,jwt,null,null).body()); + assertEquals(403,call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",""),draft(),null).statusCode()); + var second=publish(2);flows.activate(type,second.id(),1,manager,"new-flow");flows.retire(first.id(),first.revision(),manager,"retire"); + var replay=postBody("{\"data\":{\"client\":\"C1\"},\"typeKey\":\""+type+"\"}",key,jwt); + assertEquals(201,replay.statusCode());assertEquals(created.body(),replay.body()); + assertEquals(1,json.readTree(call("GET",location,jwt,null,null).body()).at("/flow/version").asInt()); + assertEquals(2,json.readTree(post(type,unique(),"{}",jwt).body()).at("/flow/version").asInt()); + var conflict=post(type,key,"{\"client\":\"different\"}",jwt);assertEquals(409,conflict.statusCode());contract(conflict,"Problem"); + assertEquals("IDEMPOTENCY_CONFLICT",json.readTree(conflict.body()).get("code").asText()); + assertEquals(1L,admin.queryForObject("SELECT count(*) FROM "+schema+".audit_entry WHERE request_id=?",Long.class,UUID.fromString(json.readTree(created.body()).get("id").asText()))); + } + @Test void ownershipScopeAndKeyNamespace()throws Exception{ + String type=type(publish(1)),key=unique();var created=post(type,key,"{}",jwt());String location=created.headers().firstValue("Location").orElseThrow(); + for(String foreign:List.of(token("FLOW_ADMIN","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner"))){ + assertEquals(404,call("GET",location,foreign,null,null).statusCode()); + var own=post(type,key,"{}",foreign);assertEquals(201,own.statusCode());assertNotEquals(created.body(),own.body()); + } + assertEquals(401,call("GET",location,null,null,null).statusCode()); + assertEquals(404,call("GET","/api/v1/requests/"+UUID.randomUUID(),jwt(),null,null).statusCode()); + } + @Test void invalidCommandsNeverReserveTheKey()throws Exception{ + String type=type(publish(1)),key=unique(),jwt=jwt(); + assertEquals(400,post(type,null,"{}",jwt).statusCode());assertEquals(401,post(type,key,"{}",null).statusCode()); + assertEquals(422,post(type,key,"{\"score\":99}",jwt).statusCode()); + assertEquals(422,post(type,key,"{\"client\":123}",jwt).statusCode()); + assertEquals(400,postBody("{\"typeKey\":\""+type+"\",\"data\":{},\"owner\":\"other\"}",key,jwt).statusCode()); + assertEquals(413,post(type,key,"{\"client\":\""+"x".repeat(262144)+"\"}",jwt).statusCode()); + assertEquals(201,post(type,key,"{}",jwt).statusCode()); + assertEquals(404,post(unique(),unique(),"{}",jwt).statusCode()); + admin.update("UPDATE "+schema+".request_type SET enabled=false WHERE type_key=?",type); + assertEquals(409,post(type,unique(),"{}",jwt).statusCode()); + assertEquals(201,post(type,key,"{}",jwt).statusCode()); + } + @Test void initialAndFinalDataSchemasAreBothValidated()throws Exception{ + var n=FlowFixtures.simple();n.put("flowKey",unique()); + n.set("initialDataSchema",FlowFixtures.node("{\"type\":\"object\",\"properties\":{\"client\":{\"type\":\"string\",\"minLength\":3}},\"required\":[\"client\"],\"additionalProperties\":false}")); + var draft=flows.create(n.toString(),manager,"create");var flow=flows.publish(draft.id(),0,manager,"publish"); + String type=type(flow),key=unique(),jwt=jwt(); + assertEquals(422,post(type,key,"{}",jwt).statusCode());assertEquals(422,post(type,key,"{\"client\":\"a\"}",jwt).statusCode()); + assertEquals(201,post(type,key,"{\"client\":\"abc\"}",jwt).statusCode()); + } + @Test void simultaneousSameKeyIsExactlyOneCreationAndConflictingCommandIsRejected()throws Exception{ + String type=type(publish(1)),key=unique(),jwt=jwt();var barrier=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + Callable> command=()->{barrier.await(10,TimeUnit.SECONDS);return post(type,key,"{}",jwt);}; + var a=pool.submit(command);var b=pool.submit(command);var x=a.get(30,TimeUnit.SECONDS);var y=b.get(30,TimeUnit.SECONDS); + assertEquals(201,x.statusCode(),x.body());assertEquals(x.body(),y.body()); + } + assertEquals(1L,admin.queryForObject("SELECT count(*) FROM "+schema+".request_instance WHERE type_key=?",Long.class,type)); + String conflict=unique();var second=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + var a=pool.submit(()->{second.await(10,TimeUnit.SECONDS);return post(type,conflict,"{\"client\":\"A\"}",jwt);}); + var b=pool.submit(()->{second.await(10,TimeUnit.SECONDS);return post(type,conflict,"{\"client\":\"B\"}",jwt);}); + assertEquals(Set.of(201,409),Set.of(a.get(30,TimeUnit.SECONDS).statusCode(),b.get(30,TimeUnit.SECONDS).statusCode())); + } + } + @Test void auditAndLedgerFailuresRollbackEveryWrite(){ + String type=type(publish(1));var service=app.getBean(Requests.class); + for(String failure:List.of("fail-audit","fail-ledger")){ + assertThrows(org.springframework.dao.DataAccessException.class,()->service.create(type,ObjectValue.empty(),failure,owner,failure)); + assertEquals(0L,admin.queryForObject("SELECT count(*) FROM "+schema+".request_instance WHERE type_key=?",Long.class,type)); + assertEquals(0L,admin.queryForObject("SELECT count(*) FROM "+schema+".request_creation_idempotency WHERE idempotency_key=?",Long.class,failure)); + } + assertNotNull(service.create(type,ObjectValue.empty(),"fail-audit",owner,"recovered")); + } + @Test void creationHoldsFlowAndTypeLocksUntilItsAtomicCommit()throws Exception{ + var initial=publish(1);String type=type(initial);var next=publish(2); + var locked=new CountDownLatch(1);var release=new CountDownLatch(1);var activating=new CountDownLatch(1); + FlowStore paused=new FlowStore(){public T transaction(Function work){return flowStore.transaction(session->{ + Session proxy=(Session)java.lang.reflect.Proxy.newProxyInstance(Session.class.getClassLoader(),new Class[]{Session.class},(p,m,args)->{ + try{Object result=m.invoke(session,args);if(m.getName().equals("assignableFlow")){locked.countDown();assertTrue(release.await(10,TimeUnit.SECONDS));}return result;} + catch(java.lang.reflect.InvocationTargetException e){throw e.getCause();}});return work.apply(proxy);});}}; + var store=new PostgresRequestStore(app.getBean(javax.sql.DataSource.class),paused,FlowFixtures.CODEC,schema); + try(var pool=Executors.newFixedThreadPool(2)){ + var creation=pool.submit(()->store.create(type,ObjectValue.empty(),unique(),owner,"creation"));assertTrue(locked.await(10,TimeUnit.SECONDS)); + var activation=pool.submit(()->{activating.countDown();return flows.activate(type,next.id(),1,manager,"activation");}); + assertTrue(activating.await(10,TimeUnit.SECONDS));release.countDown(); + var result=creation.get(20,TimeUnit.SECONDS);activation.get(20,TimeUnit.SECONDS);assertEquals(initial.id(),result.request().flowId()); + assertEquals(next.id(),app.getBean(Requests.class).create(type,ObjectValue.empty(),unique(),owner,"next").request().flowId()); + }finally{release.countDown();} + flows.retire(next.id(),next.revision(),manager,"retire"); + assertThrows(DomainException.class,()->app.getBean(Requests.class).create(type,ObjectValue.empty(),unique(),owner,"retired")); + } + @Test void getIncludesActiveExecutionButCreationReplayPreservesOriginalResponse()throws Exception{ + String type=type(publish(1)),key=unique(),jwt=jwt();var created=post(type,key,"{}",jwt);UUID id=UUID.fromString(json.readTree(created.body()).get("id").asText()),execution=UUID.randomUUID(); + admin.update("INSERT INTO "+schema+".execution(id,request_id,idempotency_key,payload_hash,actor_subject,actor_snapshot,event_key,event_data,transition_id,from_state,accepted_revision,status) VALUES (?,?,'key',?,'owner','{}','GO','{}','go','START',0,'PENDING')",execution,id,"a".repeat(64)); + admin.update("UPDATE "+schema+".request_instance SET revision=revision+1 WHERE id=?",id); + var current=call("GET","/api/v1/requests/"+id,jwt,null,null);contract(current,"RequestView");assertEquals("\"1\"",current.headers().firstValue("ETag").orElseThrow()); + assertEquals(execution.toString(),json.readTree(current.body()).get("activeExecutionId").asText()); + assertEquals(created.body(),post(type,key,"{}",jwt).body()); + } + @Test void retirementWaitsForAcceptedCreationAndPreventsEveryLaterAssignment()throws Exception{ + var initial=publish(1);String type=type(initial);var assigned=new CountDownLatch(1);var release=new CountDownLatch(1); + FlowStore paused=new FlowStore(){public T transaction(Function work){return flowStore.transaction(session->{ + Session proxy=(Session)java.lang.reflect.Proxy.newProxyInstance(Session.class.getClassLoader(),new Class[]{Session.class},(p,m,args)->{ + try{Object result=m.invoke(session,args);if(m.getName().equals("assignableFlow")){assigned.countDown();assertTrue(release.await(10,TimeUnit.SECONDS));}return result;} + catch(java.lang.reflect.InvocationTargetException e){throw e.getCause();}});return work.apply(proxy);});}}; + var store=new PostgresRequestStore(app.getBean(javax.sql.DataSource.class),paused,FlowFixtures.CODEC,schema);String key=unique(); + try(var pool=Executors.newFixedThreadPool(2)){ + var creation=pool.submit(()->store.create(type,ObjectValue.empty(),key,owner,"create"));assertTrue(assigned.await(10,TimeUnit.SECONDS)); + var retiring=pool.submit(()->flows.retire(initial.id(),initial.revision(),manager,"retire"));release.countDown(); + var accepted=creation.get(20,TimeUnit.SECONDS);retiring.get(20,TimeUnit.SECONDS); + assertEquals(initial.id(),app.getBean(Requests.class).get(accepted.request().id(),owner).request().flowId()); + assertEquals(accepted,app.getBean(Requests.class).create(type,ObjectValue.empty(),key,owner,"replay")); + assertThrows(DomainException.class,()->app.getBean(Requests.class).create(type,ObjectValue.empty(),unique(),owner,"new")); + }finally{release.countDown();} + } +} diff --git a/src/test/java/com/organizacion/orchestrator/RequestJsonTest.java b/src/test/java/com/organizacion/orchestrator/RequestJsonTest.java new file mode 100644 index 0000000..166c8c7 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/RequestJsonTest.java @@ -0,0 +1,24 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.request.infrastructure.RequestJson; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class RequestJsonTest { + final RequestJson json=new RequestJson(FlowFixtures.CODEC); + @Test void strictCommandRejectsIdentityAndMalformedOrAmbiguousJson(){ + for(String input:new String[]{"","null","[]","{}","{\"typeKey\":\"T\",\"data\":{},\"owner\":\"x\"}", + "{\"typeKey\":\"T\",\"data\":{},\"data\":{}}","{\"typeKey\":\"T\",\"data\":{}} {}"}) + assertThrows(IllegalArgumentException.class,()->json.command(input),input); + } + @Test void canonicalCommandsPreserveDecimalPrecisionAndIgnoreObjectOrder(){ + var a=json.command("{\"typeKey\":\"T\",\"data\":{\"b\":1.00,\"a\":0.12345678901234567890123456789}}"); + var b=json.command("{\"data\":{\"a\":0.12345678901234567890123456789,\"b\":1},\"typeKey\":\"T\"}"); + assertEquals(json.command(a.typeKey(),a.data()),json.command(b.typeKey(),b.data())); + assertTrue(json.data(a.data()).contains("0.12345678901234567890123456789")); + } + @Test void dataLimitCountsUtf8AndCanonicalExpansion(){ + assertThrows(RequestJson.DataTooLarge.class,()->json.command("{\"typeKey\":\"T\",\"data\":{\"x\":\""+"é".repeat(131072)+"\"}}")); + assertThrows(IllegalArgumentException.class,()->json.command("{\"typeKey\":\"T\",\"data\":{\"x\":\"\\u0000\"}}")); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/RetryPolicyTest.java b/src/test/java/com/organizacion/orchestrator/RetryPolicyTest.java new file mode 100644 index 0000000..25abbe2 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/RetryPolicyTest.java @@ -0,0 +1,26 @@ +package com.organizacion.orchestrator; +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.integration.infrastructure.HttpIntegrationHandler; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; + +class RetryPolicyTest { + @Test void boundedBackoffJitterAndTotalAttempts(){ + assertEquals(1600,RetryDelay.after(RetryPolicy.DEFAULT,1,Optional.empty(),0).toMillis()); + assertEquals(2000,RetryDelay.after(RetryPolicy.DEFAULT,1,Optional.empty(),0.5).toMillis()); + assertEquals(4000,RetryDelay.after(RetryPolicy.DEFAULT,2,Optional.empty(),0.9).toMillis()); + assertEquals(120000,RetryDelay.after(RetryPolicy.DEFAULT,2,Optional.of(Duration.ofHours(1)),0).toMillis()); + assertThrows(IllegalArgumentException.class,()->RetryDelay.after(RetryPolicy.DEFAULT,3,Optional.empty(),0)); + assertThrows(IllegalArgumentException.class,()->new RetryPolicy(4,Duration.ofSeconds(2),Duration.ofSeconds(4))); + } + @Test void parsesRetryAfterDateDeltaAndRejectsMalformedValues(){ + var now=Instant.parse("2026-10-27T12:00:00Z"); + assertEquals(Duration.ofSeconds(12),HttpIntegrationHandler.retryAfter(Optional.of("12"),now).orElseThrow()); + assertEquals(Duration.ofSeconds(120),HttpIntegrationHandler.retryAfter(Optional.of("999999999999999999999"),now).orElseThrow()); + assertEquals(Duration.ofSeconds(20),HttpIntegrationHandler.retryAfter(Optional.of("Tue, 27 Oct 2026 12:00:20 GMT"),now).orElseThrow()); + for(String value:new String[]{"-1","soon","3.5"})assertTrue(HttpIntegrationHandler.retryAfter(Optional.of(value),now).isEmpty()); + assertEquals(Duration.ZERO,HttpIntegrationHandler.retryAfter(Optional.of("Tue, 27 Oct 2026 11:00:00 GMT"),now).orElseThrow()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/RetryReconciliationIT.java b/src/test/java/com/organizacion/orchestrator/RetryReconciliationIT.java new file mode 100644 index 0000000..e6c80d4 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/RetryReconciliationIT.java @@ -0,0 +1,188 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.execution.infrastructure.*; +import com.organizacion.orchestrator.shared.domain.ErrorCode; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.net.URI; +import java.net.http.*; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static com.organizacion.orchestrator.WorkerIT.*; + +class RetryReconciliationIT { + @TempDir static Path directory; + @BeforeAll static void setup()throws Exception{ + var catalog=FlowFixtures.JSON.createObjectNode();catalog.set("roles",FlowFixtures.node("[\"ANALISTA\"]")); + catalog.set("handlers",FlowFixtures.node("{\"INVOKE_INTEGRATION\":{\"effect\":\"NON_IDEMPOTENT_WRITE\",\"configurationSchema\":{},\"inputSchema\":{},\"outputSchema\":{}}}")); + var contract=FlowFixtures.catalog().integrations().get("LOOKUP").contracts().get("1"); + var c=FlowFixtures.JSON.createObjectNode();c.set("inputSchema",FlowFixtures.CODEC.tree(contract.input().value()));c.set("outputSchema",FlowFixtures.CODEC.tree(contract.output().value())); + c.put("effect","NON_IDEMPOTENT_WRITE");c.set("profiles",FlowFixtures.node("{\"BASIC\":[\"1\"]}"));c.set("defaultProfiles",FlowFixtures.node("{\"BASIC\":\"1\"}")); + catalog.putObject("integrations").putObject("LOOKUP").put("defaultVersion","1").putObject("contracts").set("1",c);catalog.putObject("documents"); + var path=directory.resolve("catalog.json");Files.writeString(path,catalog.toString()); + RequestCreationIT.setup(true,"--orchestrator.flow.catalog-path="+path);WorkerIT.store=fresh(); + } + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + @BeforeEach void isolate(){new WorkerIT().isolateQueue();} + static void due(UUID id){admin.update("UPDATE "+schema+".execution SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE id=?",id);} + static UUID uncertain()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}"); + engine(actions(Effect.NON_IDEMPOTENT_WRITE,(a,c)->new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.UNKNOWN_EFFECT))).run(claim("uncertain"));assertEquals("RECONCILIATION_REQUIRED",status(id));return id; + } + static String resume(int score){return "{\"mode\":\"RESUME\",\"evidence\":{\"reference\":\"CASE-SYNTHETIC\",\"reason\":\"Operator verified synthetic provider result\"},\"verifiedActions\":[{\"actionId\":\"lookup\",\"result\":{\"score\":"+score+"},\"providerReference\":\"MOCK-1\"}]}";} + static String close(){return "{\"mode\":\"CLOSE_WITHOUT_TRANSITION\",\"evidence\":{\"reference\":\"CASE-SYNTHETIC\",\"reason\":\"Synthetic compensation verified by operator\"},\"effectStatus\":\"COMPENSATED_VERIFIED\"}";} + static HttpResponse resolve(UUID id,String key,String body,String jwt)throws Exception{ + var b=HttpRequest.newBuilder(URI.create(base+"/api/v1/admin/executions/"+id+"/resolve")).timeout(Duration.ofSeconds(30)).header("Content-Type","application/json"); + if(key!=null)b.header("Idempotency-Key",key);if(jwt!=null)b.header("Authorization","Bearer "+jwt); + return http.send(b.POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + } + static String operator()throws Exception{return token("EXECUTION_OPERATOR","scope-a","otherowner");} + @Test void realHttpRetryAfterSchedulesThenSucceedsWithStableKey()throws Exception{ + try(var provider=new HttpProviderFixture()){ + provider.status=429;provider.retryAfter="120";var h=provider.handler(); + var id=pending(flow(true),"{\"client\":\"C1\"}");var adapter=actions(Effect.READ_ONLY,h::execute); + engine(adapter).run(claim("rate-limited"));assertEquals("RETRY_WAIT",status(id)); + assertTrue(admin.queryForObject("SELECT next_attempt_at>clock_timestamp()+interval '100 seconds' FROM "+schema+".execution WHERE id=?",Boolean.class,id)); + provider.status=200;due(id);engine(adapter).run(claim("retry")); + assertEquals("SUCCEEDED",status(id));assertEquals(2,provider.calls.get());assertEquals(1,provider.effectKeys.size()); + } + } + @Test void exhaustedIdempotentWriteRemainsBlockedForReconciliation()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}"); + var adapter=actions(Effect.IDEMPOTENT_WRITE,(a,c)->new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.TRANSIENT)); + for(int i=0;i<3;i++){engine(adapter).run(claim("retry-write"));if(i<2)due(id);} + assertEquals("RECONCILIATION_REQUIRED",status(id)); + UUID request=admin.queryForObject("SELECT request_id FROM "+schema+".execution WHERE id=?",UUID.class,id); + assertEquals(409,EventAcceptanceIT.event(request,unique(),1).statusCode()); + } + @Test void retryAuditFailureRollsBackScheduleAndRecovers()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var token=claim("before-retry"); + var adapter=actions(Effect.READ_ONLY,(a,c)->new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.TRANSIENT)); + admin.execute("CREATE FUNCTION "+schema+".fail_retry() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='RETRY_SCHEDULED' THEN RAISE EXCEPTION 'test retry failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_retry BEFORE INSERT ON "+schema+".audit_entry FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_retry()"); + try{assertThrows(org.springframework.dao.DataAccessException.class,()->engine(adapter).run(token));}finally{admin.execute("DROP TRIGGER test_retry ON "+schema+".audit_entry");} + assertEquals("RUNNING",status(id));assertEquals("RUNNING",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + expire(token);engine(actions(Effect.READ_ONLY,(a,c)->ok(70))).run(claim("recovered")); + assertEquals("SUCCEEDED",status(id)); + } + @Test void transientRetriesAreDurableBoundedAndNeverBusinessNegative()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var calls=new AtomicInteger();var keys=new HashSet(); + var adapter=actions(Effect.READ_ONLY,(a,c)->{calls.incrementAndGet();keys.add(c.externalIdempotencyKey());return new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.TRANSIENT,Optional.of(Duration.ofSeconds(120)));}); + for(int attempt=1;attempt<=3;attempt++){ + var token=claim("retry-"+attempt);new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(token); + assertEquals(attempt<3?"RETRY_WAIT":"FAILED",status(id)); + if(attempt<3){assertTrue(store.claim("early",1,LEASE).isEmpty());assertTrue(admin.queryForObject("SELECT next_attempt_at>clock_timestamp()+interval '100 seconds' FROM "+schema+".execution WHERE id=?",Boolean.class,id));due(id);} + } + assertEquals(3,calls.get());assertEquals(1,keys.size()); + assertNull(admin.queryForObject("SELECT result FROM "+schema+".execution WHERE id=?",String.class,id)); + assertEquals(2,admin.queryForObject("SELECT count(*) FROM "+schema+".audit_entry WHERE execution_id=? AND kind='RETRY_SCHEDULED'",Integer.class,id)); + } + @Test void earlierSuccessIsNotRepeatedWhenNextActionRetries()throws Exception{ + var n=FlowFixtures.integration();n.put("flowKey",unique());var second=(com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0/actions/0").deepCopy();second.put("id","second").put("resultKey","second");((com.fasterxml.jackson.databind.node.ArrayNode)n.at("/transitions/0/actions")).add(second); + UUID id=pending(publish(n),"{\"client\":\"C1\"}");var first=new AtomicInteger();var later=new AtomicInteger(); + var adapter=actions(Effect.READ_ONLY,(a,c)->{if(a.id().equals("lookup")){first.incrementAndGet();return ok(70);}return later.incrementAndGet()==1?new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.TRANSIENT):ok(80);}); + engine(adapter).run(claim("first"));assertEquals("RETRY_WAIT",status(id));due(id);new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(claim("second")); + assertEquals("SUCCEEDED",status(id));assertEquals(1,first.get());assertEquals(2,later.get()); + } + @Test void resumeIsAuthorizedAtomicAndReplayRemainsStableAfterCompletion()throws Exception{ + UUID id=uncertain();String key=unique();String jwt=operator(); + assertEquals(403,resolve(id,key,resume(70),RequestCreationIT.jwt()).statusCode()); + assertEquals(401,resolve(id,key,resume(70),null).statusCode()); + assertEquals(403,resolve(id,key,resume(70),token("EXECUTION_OPERATOR","scope-b","")).statusCode()); + assertEquals(422,resolve(id,key,resume(70).replace("\"score\":70","\"score\":\"70\""),jwt).statusCode()); + assertEquals("RECONCILIATION_REQUIRED",status(id)); + var result=resolve(id,key,resume(70),jwt);assertEquals(200,result.statusCode(),result.body());RequestCreationIT.contract(result,"ReconciliationView");assertEquals("PENDING",status(id)); + assertEquals(2L,admin.queryForObject("SELECT fencing_token FROM "+schema+".execution WHERE id=?",Long.class,id)); + engine(actions(Effect.NON_IDEMPOTENT_WRITE,(a,c)->{throw new AssertionError("Verified action must not repeat");})).run(claim("resumed")); + assertEquals("SUCCEEDED",status(id));assertEquals(result.body(),resolve(id,key,resume(70),jwt).body()); + assertEquals(409,resolve(id,key,resume(71),jwt).statusCode());assertEquals(409,resolve(id,unique(),resume(70),jwt).statusCode()); + assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".reconciliation_resolution WHERE execution_id=?",Integer.class,id)); + } + @Test void closeRequiresEvidenceAndPreservesBusinessData()throws Exception{ + UUID id=uncertain();var request=admin.queryForObject("SELECT request_id FROM "+schema+".execution WHERE id=?",UUID.class,id); + assertEquals(422,resolve(id,unique(),close().replace("COMPENSATED_VERIFIED","ASSUMED"),operator()).statusCode()); + assertEquals(422,resolve(id,unique(),close().replace("CASE-SYNTHETIC"," "),operator()).statusCode()); + var response=resolve(id,unique(),close(),operator());assertEquals(200,response.statusCode(),response.body()); + assertEquals("FAILED",status(id));assertEquals("START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,request)); + assertEquals(1L,admin.queryForObject("SELECT revision FROM "+schema+".request_instance WHERE id=?",Long.class,request)); + assertEquals(202,EventAcceptanceIT.event(request,unique(),1).statusCode()); + } + @Test void resolutionAuditFailureRollsBackCheckpointLedgerAndQueue()throws Exception{ + UUID id=uncertain();String key=unique(); + admin.execute("CREATE FUNCTION "+schema+".fail_resolution() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='EXECUTION_RESOLVED' THEN RAISE EXCEPTION 'test resolution failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_resolution BEFORE INSERT ON "+schema+".audit_entry FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_resolution()"); + try{assertEquals(503,resolve(id,key,resume(70),operator()).statusCode());}finally{admin.execute("DROP TRIGGER test_resolution ON "+schema+".audit_entry");} + assertEquals("RECONCILIATION_REQUIRED",status(id));assertEquals("UNKNOWN",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + assertEquals(0,admin.queryForObject("SELECT count(*) FROM "+schema+".reconciliation_resolution WHERE execution_id=?",Integer.class,id)); + assertEquals(200,resolve(id,key,resume(70),operator()).statusCode()); + } + @Test void concurrentResolversShareOneResolution()throws Exception{ + UUID id=uncertain();String key=unique(),jwt=operator();var barrier=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + var a=pool.submit(()->{barrier.await();return resolve(id,key,resume(70),jwt);}); + var b=pool.submit(()->{barrier.await();return resolve(id,key,resume(70),jwt);}); + var ra=a.get(40,TimeUnit.SECONDS);var rb=b.get(40,TimeUnit.SECONDS);assertEquals(200,ra.statusCode(),ra.body());assertEquals(ra.body(),rb.body()); + } + assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".audit_entry WHERE execution_id=? AND kind='EXECUTION_RESOLVED'",Integer.class,id)); + } + @Test void differentKeysCannotApplyTwoConcurrentResolutions()throws Exception{ + UUID id=uncertain();String jwt=operator();var barrier=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + var a=pool.submit(()->{barrier.await();return resolve(id,unique(),close(),jwt);}); + var b=pool.submit(()->{barrier.await();return resolve(id,unique(),close(),jwt);}); + assertEquals(Set.of(200,409),Set.of(a.get(40,TimeUnit.SECONDS).statusCode(),b.get(40,TimeUnit.SECONDS).statusCode())); + } + assertEquals(1,admin.queryForObject("SELECT count(*) FROM "+schema+".reconciliation_resolution WHERE execution_id=?",Integer.class,id)); + } + @Test void rejectsIncompleteEvidenceUnknownActionsAndFalseAbsence()throws Exception{ + var n=FlowFixtures.integration();n.put("flowKey",unique());var second=(com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0/actions/0").deepCopy();second.put("id","second").put("resultKey","second");((com.fasterxml.jackson.databind.node.ArrayNode)n.at("/transitions/0/actions")).add(second); + UUID id=pending(publish(n),"{\"client\":\"C1\"}"); + engine(actions(Effect.NON_IDEMPOTENT_WRITE,(a,c)->a.id().equals("lookup")?ok(70):new ActionResult.TechnicalFailure(ErrorCode.INTEGRATION_UNAVAILABLE,ActionResult.FailureKind.UNKNOWN_EFFECT))).run(claim("two-actions")); + assertEquals(422,resolve(id,unique(),resume(70),operator()).statusCode()); // unresolved second action + assertEquals(422,resolve(id,unique(),resume(70).replace("\"lookup\"","\"invented\""),operator()).statusCode()); + assertEquals(422,resolve(id,unique(),close().replace("COMPENSATED_VERIFIED","ABSENT_VERIFIED"),operator()).statusCode()); + assertEquals("RECONCILIATION_REQUIRED",status(id)); + assertEquals(200,resolve(id,unique(),resume(80).replace("\"lookup\"","\"second\""),operator()).statusCode()); + engine(actions(Effect.NON_IDEMPOTENT_WRITE,(a,c)->{throw new AssertionError("No repeat");})).run(claim("verified")); + assertEquals("SUCCEEDED",status(id)); + } + @Test void hidesForeignResourceAndRejectsMalformedCommands()throws Exception{ + UUID id=uncertain();String jwt=operator(); + assertEquals(400,resolve(id,null,resume(70),jwt).statusCode()); + assertEquals(400,resolve(id,unique(),"{",jwt).statusCode()); + assertEquals(422,resolve(id,unique(),resume(70).replace("\"mode\":\"RESUME\"","\"mode\":\"RESUME\",\"to\":\"END\""),jwt).statusCode()); + assertEquals(404,resolve(UUID.randomUUID(),unique(),resume(70),jwt).statusCode()); + UUID request=admin.queryForObject("SELECT request_id FROM "+schema+".execution WHERE id=?",UUID.class,id); + admin.update("UPDATE "+schema+".request_instance SET business_scope='scope-b' WHERE id=?",request); + assertEquals(404,resolve(id,unique(),resume(70),jwt).statusCode()); + } + @ParameterizedTest @ValueSource(strings={"READ_ONLY:BEFORE_SEND","IDEMPOTENT_WRITE:AFTER_SEND","IDEMPOTENT_WRITE:AFTER_CHECKPOINT","NON_IDEMPOTENT_WRITE:AFTER_SEND","NON_IDEMPOTENT_WRITE:BEFORE_SEND"}) + void realChildJvmCrashAtEffectBoundaryRecoversOrRequiresResolution(String scenario)throws Exception{ + String effect=scenario.split(":")[0],boundary=scenario.split(":")[1];boolean safe=!effect.equals("NON_IDEMPOTENT_WRITE");int initialCalls=boundary.equals("AFTER_CHECKPOINT")?2:boundary.equals("AFTER_SEND")?1:0; + try(var provider=new HttpProviderFixture()){ + var n=FlowFixtures.integration();n.put("flowKey",unique()); + if(boundary.equals("AFTER_CHECKPOINT")){var second=(com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0/actions/0").deepCopy();second.put("id","second").put("resultKey","second");((com.fasterxml.jackson.databind.node.ArrayNode)n.at("/transitions/0/actions")).add(second);} + UUID id=pending(publish(n),"{\"client\":\"C1\"}"); + var builder=new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin","java").toString(),"-cp",System.getProperty("surefire.test.class.path",System.getProperty("java.class.path")),CrashWorkerProcess.class.getName(),effect,boundary,provider.uri().toString()); + builder.environment().put("TEST_JDBC",runtime.getUrl());builder.environment().put("TEST_USER",runtime.getUsername());builder.environment().put("TEST_PASSWORD",runtime.getPassword());builder.environment().put("TEST_SCHEMA",schema); + builder.redirectErrorStream(true).redirectOutput(directory.resolve("child-"+effect+".log").toFile()); + var child=builder.start();try{assertTrue(child.waitFor(45,TimeUnit.SECONDS),"Child did not finish");assertEquals(23,child.exitValue(),Files.readString(directory.resolve("child-"+effect+".log")));}finally{if(child.isAlive())child.destroyForcibly();} + assertEquals(initialCalls,provider.calls.get());assertEquals("RUNNING",status(id)); + admin.update("UPDATE "+schema+".execution SET lease_until=clock_timestamp()-interval '1 second' WHERE id=?",id); + var h=provider.handler();var adapter=actions(Effect.valueOf(effect),(a,c)->h.execute(a,c)); + new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(claim("recovery")); + assertEquals(safe?"SUCCEEDED":"RECONCILIATION_REQUIRED",status(id)); + assertEquals(initialCalls+(safe?1:0),provider.calls.get());assertEquals(Math.max(initialCalls,safe?1:0),provider.effectKeys.size()); + if(boundary.equals("AFTER_CHECKPOINT"))assertEquals(1,admin.queryForObject("SELECT attempt_count FROM "+schema+".action_execution WHERE execution_id=? AND action_id='lookup'",Integer.class,id)); + } + } +} diff --git a/src/test/java/com/organizacion/orchestrator/SecurityHttpIT.java b/src/test/java/com/organizacion/orchestrator/SecurityHttpIT.java new file mode 100644 index 0000000..c9cdca5 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/SecurityHttpIT.java @@ -0,0 +1,174 @@ +package com.organizacion.orchestrator; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.*; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.*; +import com.sun.net.httpserver.HttpServer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.organizacion.orchestrator.shared.infrastructure.security.RuntimeRoleVerifier; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.*; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.testcontainers.postgresql.PostgreSQLContainer; +import java.net.*; +import java.net.http.*; +import java.time.Instant; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Actual RSA verification, HTTP filters/controllers and a separate LOGIN role on an isolated schema. */ +class SecurityHttpIT { + static final String suffix=UUID.randomUUID().toString().replace("-",""); + static final String schema="t07_"+suffix, role="t07_runtime_"+suffix; + static final String secret=UUID.randomUUID().toString(); + static JdbcTemplate admin; static boolean schemaCreated,roleCreated; + static ConfigurableApplicationContext app; static HttpServer keys;static PostgreSQLContainer container; + static RSAKey key;static String issuer,base; static DriverManagerDataSource runtime; + static final HttpClient http=HttpClient.newHttpClient();static final ObjectMapper json=new ObjectMapper(); + @BeforeAll static void start()throws Exception{start(true,false);} + static void start(boolean administration,boolean requests,String... extra)throws Exception{ + String url,user,password; + if(Boolean.getBoolean("migration.test.external")){ + url=Objects.requireNonNull(System.getenv("MIGRATION_TEST_DB_URL")); + assertTrue(url.matches("jdbc:postgresql://[A-Za-z0-9.-]+:[0-9]+/orquestador_test")); + user=System.getenv("MIGRATION_TEST_DB_USER");password=System.getenv("MIGRATION_TEST_DB_PASSWORD"); + }else{ + container=new PostgreSQLContainer("postgres:9.6.24").withDatabaseName("orquestador_test");container.start(); + url=container.getJdbcUrl();user=container.getUsername();password=container.getPassword(); + } + var owner=new DriverManagerDataSource(url,user,password);admin=new JdbcTemplate(owner); + assertEquals("orquestador_test",admin.queryForObject("SELECT current_database()",String.class)); + admin.execute("CREATE SCHEMA "+schema);schemaCreated=true; + Flyway.configure().dataSource(owner).schemas(schema).defaultSchema(schema).createSchemas(false) + .placeholders(Map.of("orchestratorSchema",schema)).load().migrate(); + admin.execute("CREATE ROLE "+role+" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD '"+secret+"'");roleCreated=true; + admin.execute("GRANT CONNECT ON DATABASE orquestador_test TO "+role); + admin.execute("SELECT "+schema+".grant_runtime_access('"+role+"'::name)"); + runtime=new DriverManagerDataSource(url,role,secret); + assertThrows(IllegalStateException.class,()->new RuntimeRoleVerifier(owner,schema)); + new RuntimeRoleVerifier(runtime,schema); + key=new RSAKeyGenerator(2048).keyID("test-key").generate();keys=HttpServer.create(new InetSocketAddress("127.0.0.1",0),0); + keys.createContext("/jwks",exchange->{byte[] body=new JWKSet(key.toPublicJWK()).toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type","application/json");exchange.sendResponseHeaders(200,body.length); + try(var out=exchange.getResponseBody()){out.write(body);}});keys.start(); + issuer="http://127.0.0.1:"+keys.getAddress().getPort(); + String[] defaults={ + "--spring.profiles.active=postgres96","--server.address=127.0.0.1","--server.port=0", + "--spring.datasource.url="+url,"--spring.datasource.username="+role,"--spring.datasource.password="+secret, + "--spring.flyway.user="+user,"--spring.flyway.password="+password, + "--spring.flyway.default-schema="+schema,"--spring.flyway.schemas="+schema,"--spring.flyway.placeholders.orchestratorSchema="+schema, + "--spring.jpa.properties.hibernate.default_schema="+schema,"--orchestrator.database.schema="+schema, + "--spring.security.oauth2.resourceserver.jwt.issuer-uri="+issuer,"--spring.security.oauth2.resourceserver.jwt.jwk-set-uri="+issuer+"/jwks", + "--spring.security.oauth2.resourceserver.jwt.audiences=orchestrator-test", + "--orchestrator.admin.enabled="+administration,"--orchestrator.requests.enabled="+requests,"--orchestrator.admin.business-scopes=scope-a","--logging.level.root=WARN"}; + app=new SpringApplication(OrchestratorApplication.class,com.organizacion.securitytest.ResourceProbe.class).run(java.util.stream.Stream.concat(Arrays.stream(defaults),Arrays.stream(extra)).toArray(String[]::new)); + base="http://127.0.0.1:"+((WebServerApplicationContext)app).getWebServer().getPort(); + } + @AfterAll static void stop(){ + try{if(app!=null)app.close();if(keys!=null)keys.stop(0); + if(admin!=null){assertEquals("orquestador_test",admin.queryForObject("SELECT current_database()",String.class)); + assertTrue(schema.matches("t07_[a-f0-9]{32}"));assertTrue(role.matches("t07_runtime_[a-f0-9]{32}")); + if(schemaCreated)admin.execute("DROP SCHEMA "+schema+" CASCADE"); + if(roleCreated){admin.execute("REVOKE CONNECT ON DATABASE orquestador_test FROM "+role);admin.execute("DROP ROLE "+role);} + } + }finally{if(container!=null)container.stop();} + } + static String token(String role,String scope,String defect)throws Exception{ + var claims=new JWTClaimsSet.Builder().issuer(issuer).subject("owner").audience("orchestrator-test") + .expirationTime(Date.from(Instant.now().plusSeconds(300))).claim("business_scope",scope) + .claim("realm_access",Map.of("roles",List.of(role))); + switch(defect){ + case "issuer"->claims.issuer("https://other.invalid");case "audience"->claims.audience("other"); + case "expired"->claims.expirationTime(Date.from(Instant.now().minusSeconds(300))); + case "future"->claims.notBeforeTime(Date.from(Instant.now().plusSeconds(300))); + case "noexp"->claims.expirationTime(null);case "nosub"->claims.subject(null); + case "noscope"->claims.claim("business_scope",null); + case "otherowner"->claims.subject("someone-else"); + case "otherclient"->claims.claim("realm_access",Map.of()).claim("resource_access",Map.of("other",Map.of("roles",List.of(role)))); + case "oauthscope"->claims.claim("realm_access",Map.of()).claim("scope",role); + case "client"->claims.claim("realm_access",Map.of()).claim("resource_access",Map.of("orchestrator-test",Map.of("roles",List.of(role)))); + default->{} + } + var jwt=new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(),claims.build()); + jwt.sign(new RSASSASigner(defect.equals("signature")?new RSAKeyGenerator(2048).generate():key));return jwt.serialize(); + } + static HttpResponse call(String method,String path,String token,String body,String match)throws Exception{ + var request=HttpRequest.newBuilder(URI.create(base+path)).timeout(java.time.Duration.ofSeconds(20)).header("Content-Type","application/json"); + if(token!=null)request.header("Authorization","Bearer "+token);if(match!=null)request.header("If-Match",match); + return http.send(request.method(method,HttpRequest.BodyPublishers.ofString(body==null?"":body)).build(),HttpResponse.BodyHandlers.ofString()); + } + static String draft(){var n=FlowFixtures.simple();n.put("flowKey","F_"+UUID.randomUUID().toString().replace("-",""));return n.toString();} + @Test void cryptographyAndMandatoryClaimsAreEnforced()throws Exception{ + assertEquals(200,call("GET","/actuator/health",null,null,null).statusCode()); + for(String bad:List.of("issuer","audience","expired","future","noexp","nosub","noscope","signature")){ + var response=call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",bad),draft(),null); + assertEquals(401,response.statusCode(),bad+response.body());assertTrue(response.headers().firstValue("WWW-Authenticate").isPresent()); + assertTrue(response.headers().firstValue("Set-Cookie").isEmpty()); + } + assertEquals(401,call("POST","/api/v1/admin/flows",null,draft(),null).statusCode()); + assertEquals(401,call("POST","/api/v1/admin/flows","malformed",draft(),null).statusCode()); + } + @Test void rolesScopeAndImmutablePublicationOverHttp()throws Exception{ + String editor=token("FLOW_EDITOR","scope-a",""),publisher=token("FLOW_PUBLISHER","scope-a",""),manager=token("FLOW_ADMIN","scope-a",""); + assertEquals(403,call("POST","/api/v1/admin/flows",publisher,draft(),null).statusCode()); + assertEquals(403,call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-b",""),draft(),null).statusCode()); + String document=draft();var created=call("POST","/api/v1/admin/flows",editor,document,null); + assertEquals(201,created.statusCode(),created.body());String path="/api/v1/admin/flows/"+json.readTree(created.body()).get("id").asText(); + assertEquals("\"0\"",created.headers().firstValue("ETag").orElseThrow()); + assertEquals(428,call("PUT",path,editor,document,null).statusCode()); + for(String invalid:List.of("*","W/\"0\"","\"0\",\"1\"","\"999999999999999999999\""))assertEquals(400,call("PUT",path,editor,document,invalid).statusCode()); + assertEquals(412,call("PUT",path,editor,document,"\"5\"").statusCode()); + assertEquals(200,call("POST",path+"/validate",editor,null,null).statusCode()); + assertEquals(403,call("POST",path+"/publish",editor,null,"\"0\"").statusCode()); + var published=call("POST",path+"/publish",publisher,null,"\"0\"");assertEquals(200,published.statusCode(),published.body()); + assertEquals(409,call("PUT",path,editor,document,"\"1\"").statusCode()); + String type="T_"+suffix;admin.update("INSERT INTO "+schema+".request_type(type_key) VALUES (?)",type); + String activation="{\"flowId\":\""+json.readTree(created.body()).get("id").asText()+"\"}"; + assertEquals(200,call("PUT","/api/v1/admin/request-types/"+type+"/active-flow",manager,activation,"\"0\"").statusCode()); + assertEquals(200,call("POST",path+"/retire",manager,null,"\"1\"").statusCode()); + } + @Test void malformedAndOversizedInputAreRejected()throws Exception{ + String editor=token("FLOW_EDITOR","scope-a",""); + for(String body:List.of("{","{\"a\":1,\"a\":2}"))assertEquals(400,call("POST","/api/v1/admin/flows",editor,body,null).statusCode()); + assertEquals(422,call("POST","/api/v1/admin/flows",editor,"{}",null).statusCode()); + assertEquals(413,call("POST","/api/v1/admin/flows",editor," ".repeat(1_048_577),null).statusCode()); + } + @Test void runtimeCannotAlterSchemaHistoryOrAudit(){ + var jdbc=new JdbcTemplate(runtime); + assertThrows(org.springframework.dao.DataAccessException.class,()->jdbc.execute("CREATE TABLE "+schema+".forbidden(id int)")); + assertThrows(org.springframework.dao.DataAccessException.class,()->jdbc.execute("DELETE FROM "+schema+".admin_audit")); + assertThrows(org.springframework.dao.DataAccessException.class,()->jdbc.queryForList("SELECT * FROM "+schema+".flyway_schema_history")); + } + @Test void resourceOwnershipAndScopeAreEnforcedAgainstPersistedParents()throws Exception{ + var created=call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",""),draft(),null); + assertEquals(201,created.statusCode(),created.body());UUID flow=UUID.fromString(json.readTree(created.body()).get("id").asText()); + String type="R_"+suffix;UUID request=UUID.randomUUID(),execution=UUID.randomUUID(),document=UUID.randomUUID(),audit=UUID.randomUUID(); + admin.update("INSERT INTO "+schema+".request_type(type_key) VALUES (?)",type); + admin.update("INSERT INTO "+schema+".request_instance(id,type_key,flow_id,current_state,owner_subject,business_scope) VALUES (?,?,?,'START','owner','scope-a')",request,type,flow); + admin.update("INSERT INTO "+schema+".execution(id,request_id,idempotency_key,payload_hash,actor_subject,actor_snapshot,event_key,event_data,transition_id,from_state,accepted_revision,status) VALUES (?,?,'key',?,'owner','{}','event','{}','transition','START',0,'PENDING')",execution,request,"a".repeat(64)); + admin.update("INSERT INTO "+schema+".document_reference(id,request_id,document_type,storage_document_id,mime_type,size_bytes,checksum,created_by) VALUES (?,?,'DOC','opaque-id','application/pdf',1,'checksum','owner')",document,request); + admin.update("INSERT INTO "+schema+".audit_entry(id,request_id,flow_id,kind,actor_subject,detail) VALUES (?,?,?,'CREATED','owner','{}')",audit,request,flow); + for(var entry:Map.of("REQUEST",request,"EXECUTION",execution,"DOCUMENT",document,"AUDIT_ENTRY",audit).entrySet()){ + String path="/api/v1/test-resources/"+entry.getKey()+"/"+entry.getValue(); + assertEquals(200,call("GET",path,token("ANALISTA","scope-a",""),null,null).statusCode()); + for(String denied:List.of(token("FLOW_ADMIN","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner"))){ + var response=call("GET",path,denied,null,null);assertEquals(404,response.statusCode(),response.body()); + assertFalse(response.body().contains(request.toString())); + } + assertEquals(404,call("GET","/api/v1/test-resources/"+entry.getKey()+"/"+UUID.randomUUID(),token("ANALISTA","scope-a",""),null,null).statusCode()); + } + assertEquals(403,call("POST","/api/v1/test-resources/resolve/"+execution,token("ANALISTA","scope-a",""),null,null).statusCode()); + assertEquals(200,call("POST","/api/v1/test-resources/resolve/"+execution,token("EXECUTION_OPERATOR","scope-a","otherowner"),null,null).statusCode()); + assertEquals(404,call("POST","/api/v1/test-resources/resolve/"+execution,token("EXECUTION_OPERATOR","scope-b",""),null,null).statusCode()); + } + @Test void onlyConfiguredClientRolesGrantAccess()throws Exception{ + for(String source:List.of("otherclient","oauthscope"))assertEquals(403,call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",source),draft(),null).statusCode()); + assertEquals(201,call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a","client"),draft(),null).statusCode()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/SecurityPolicyTest.java b/src/test/java/com/organizacion/orchestrator/SecurityPolicyTest.java new file mode 100644 index 0000000..fac1cac --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/SecurityPolicyTest.java @@ -0,0 +1,51 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.Identifiers.RequestId; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.jwt.Jwt; +import java.time.Instant; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class SecurityPolicyTest { + private Jwt.Builder jwt(){return Jwt.withTokenValue("unit-only").header("alg","RS256").subject("owner") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)).claim("business_scope","scope-a");} + @Test void realmAndConfiguredClientRolesAreMappedWithoutTrustingOtherClientsOrOauthScopes(){ + var token=jwt().claim("realm_access",Map.of("roles",List.of("ANALISTA"))) + .claim("resource_access",Map.of("orchestrator",Map.of("roles",List.of("FLOW_EDITOR")), + "other",Map.of("roles",List.of("FLOW_ADMIN")))).claim("scope","FLOW_PUBLISHER FLOW_ADMIN").build(); + var actor=new JwtActorMapper("business_scope","orchestrator").actor(token); + assertEquals(Set.of("ANALISTA","FLOW_EDITOR"),actor.roles());assertEquals("scope-a",actor.businessScope()); + } + @Test void missingOrAmbiguousScopeIsDeniedAndClaimNameIsConfigurable(){ + var mapper=new JwtActorMapper("business_scope","orchestrator"); + assertThrows(OAuth2AuthenticationException.class,()->mapper.actor(jwt().claim("business_scope",List.of("a","b")).build())); + assertThrows(OAuth2AuthenticationException.class,()->mapper.actor(jwt().claim("business_scope"," ").build())); + assertThrows(OAuth2AuthenticationException.class,()->mapper.actor(jwt().claim("realm_access",Map.of("roles",List.of(1))).build())); + assertEquals("tenant",new JwtActorMapper("tenant_id","orchestrator").actor(jwt().claim("tenant_id","tenant").build()).businessScope()); + } + @ParameterizedTest @EnumSource(ResourceOwnershipStore.Kind.class) + void ownershipAndScopeAreRequiredForEveryResourceKind(ResourceOwnershipStore.Kind kind){ + var owner=new ResourceOwner(new RequestId(UUID.randomUUID()),"owner","scope-a"); + var access=new ResourceAccess((type,id)->Optional.of(owner));UUID id=UUID.randomUUID(); + assertEquals(owner,access.require(new Actor("owner","scope-a",Set.of()),kind,id)); + for(var actor:List.of(new Actor("owner","scope-b",Set.of("FLOW_ADMIN")),new Actor("other","scope-a",Set.of("FLOW_ADMIN")))) + assertEquals(ErrorCode.RESOURCE_NOT_FOUND,assertThrows(DomainException.class,()->access.require(actor,kind,id)).code()); + assertEquals(ErrorCode.RESOURCE_NOT_FOUND,assertThrows(DomainException.class,()->new ResourceAccess((type,key)->Optional.empty()) + .require(new Actor("owner","scope-a",Set.of()),kind,id)).code()); + } + @Test void resolutionOperatorIsDelegatedOnlyWithinTheSameScope(){ + var owner=new ResourceOwner(new RequestId(UUID.randomUUID()),"owner","scope-a"); + var access=new ResourceAccess((kind,id)->Optional.of(owner));UUID id=UUID.randomUUID(); + assertEquals(owner,access.requireResolution(new Actor("operator","scope-a",Set.of("EXECUTION_OPERATOR")),id)); + assertEquals(ErrorCode.ACCESS_DENIED,assertThrows(DomainException.class,()->access.requireResolution(new Actor("owner","scope-a",Set.of()),id)).code()); + assertEquals(ErrorCode.RESOURCE_NOT_FOUND,assertThrows(DomainException.class,()->access.requireResolution(new Actor("operator","scope-b",Set.of("EXECUTION_OPERATOR")),id)).code()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/SimulationIT.java b/src/test/java/com/organizacion/orchestrator/SimulationIT.java new file mode 100644 index 0000000..7da8127 --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/SimulationIT.java @@ -0,0 +1,34 @@ +package com.organizacion.orchestrator; + +import org.junit.jupiter.api.*; +import java.nio.file.Path; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; + +class SimulationIT { + @BeforeAll static void setup()throws Exception{SecurityHttpIT.start(true,true,"--orchestrator.flow.catalog-path="+Path.of("examples/integrations/publication-catalog.json").toAbsolutePath());} + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + static UUID draftFlow()throws Exception{ + var f=FlowFixtures.simple();f.put("flowKey",RequestCreationIT.unique());var r=call("POST","/api/v1/admin/flows",token("FLOW_EDITOR","scope-a",""),f.toString(),null);assertEquals(201,r.statusCode(),r.body());return UUID.fromString(json.readTree(r.body()).get("id").asText()); + } + static Map counts(){var counts=new TreeMap();for(String table:List.of("flow_definition","admin_audit","request_instance","execution","action_execution","audit_entry","document_reference","request_creation_idempotency","reconciliation_resolution"))counts.put(table,admin.queryForObject("SELECT count(*) FROM "+schema+"."+table,Integer.class));return counts;} + @Test void simulationIsAuthorizedReadOnlyAndConformsToContract()throws Exception{ + var id=draftFlow();String path="/api/v1/admin/flows/"+id+"/simulate",input=FlowSimulationTest.fixture().toString();var before=counts(); + assertEquals(401,call("POST",path,null,input,null).statusCode()); + for(String jwt:List.of(token("ANALISTA","scope-a",""),token("FLOW_PUBLISHER","scope-a",""),token("FLOW_EDITOR","scope-b","")))assertEquals(403,call("POST",path,jwt,input,null).statusCode()); + var r=call("POST",path,token("FLOW_EDITOR","scope-a",""),input,null);assertEquals(200,r.statusCode(),r.body());RequestCreationIT.contract(r,"SimulationResult"); + assertEquals("END",json.readTree(r.body()).get("toState").asText());assertEquals("no-store",r.headers().firstValue("Cache-Control").orElseThrow());assertEquals(before,counts()); + assertEquals(0L,admin.queryForObject("SELECT revision FROM "+schema+".flow_definition WHERE id=?",Long.class,id)); + assertEquals(404,call("POST","/api/v1/admin/flows/"+UUID.randomUUID()+"/simulate",token("FLOW_EDITOR","scope-a",""),input,null).statusCode()); + } + @Test void malformedOversizedAndForgedFixtureIdentityDoNotBypassAuthentication()throws Exception{ + String path="/api/v1/admin/flows/"+draftFlow()+"/simulate",editor=token("FLOW_EDITOR","scope-a",""); + assertEquals(400,call("POST",path,editor,"{",null).statusCode());assertEquals(422,call("POST",path,editor,"{}",null).statusCode()); + assertEquals(413,call("POST",path,editor,"x".repeat(1_048_577),null).statusCode()); + var fixture=FlowSimulationTest.fixture();fixture.putObject("actor").put("subject","admin").put("businessScope","scope-a").putArray("roles").add("FLOW_EDITOR").add("ANALISTA"); + assertEquals(403,call("POST",path,token("ANALISTA","scope-a",""),fixture.toString(),null).statusCode()); + ((com.fasterxml.jackson.databind.node.ArrayNode)fixture.at("/actor/roles")).removeAll(); + assertEquals(403,call("POST",path,editor,fixture.toString(),null).statusCode()); + } +} diff --git a/src/test/java/com/organizacion/orchestrator/WorkerIT.java b/src/test/java/com/organizacion/orchestrator/WorkerIT.java new file mode 100644 index 0000000..d30fefb --- /dev/null +++ b/src/test/java/com/organizacion/orchestrator/WorkerIT.java @@ -0,0 +1,184 @@ +package com.organizacion.orchestrator; + +import com.organizacion.orchestrator.action.domain.*; +import com.organizacion.orchestrator.action.domain.ActionCapability.Effect; +import com.organizacion.orchestrator.execution.application.port.*; +import com.organizacion.orchestrator.execution.domain.WorkerState.*; +import com.organizacion.orchestrator.execution.infrastructure.*; +import com.organizacion.orchestrator.flow.domain.FlowVersion; +import com.organizacion.orchestrator.shared.domain.*; +import com.organizacion.orchestrator.shared.domain.JsonValue.*; +import org.junit.jupiter.api.*; +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static com.organizacion.orchestrator.SecurityHttpIT.*; +import static org.junit.jupiter.api.Assertions.*; + +class WorkerIT { + static PostgresWorkerStore store;static final Duration LEASE=Duration.ofSeconds(60); + @BeforeAll static void setup()throws Exception{RequestCreationIT.setup();store=fresh();} + @AfterAll static void cleanup(){SecurityHttpIT.stop();} + static PostgresWorkerStore fresh(){return new PostgresWorkerStore(app.getBean(DataSource.class),FlowFixtures.CODEC,schema);} + @BeforeEach void isolateQueue(){admin.update("UPDATE "+schema+".execution SET status='FAILED',lease_owner=NULL,lease_until=NULL WHERE status IN ('PENDING','RETRY_WAIT','RUNNING','RECONCILIATION_REQUIRED')");} + static String unique(){return RequestCreationIT.unique();} + static FlowVersion flow(boolean integrations){var n=integrations?FlowFixtures.integration():FlowFixtures.simple();n.put("flowKey",unique());return publish(n);} + static FlowVersion publish(com.fasterxml.jackson.databind.node.ObjectNode n){var draft=RequestCreationIT.flows.create(n.toString(),RequestCreationIT.manager,"create");return RequestCreationIT.flows.publish(draft.id(),0,RequestCreationIT.manager,"publish");} + static UUID pending(FlowVersion flow,String data)throws Exception{ + String type=RequestCreationIT.type(flow);var created=RequestCreationIT.post(type,unique(),data,RequestCreationIT.jwt());assertEquals(201,created.statusCode(),created.body()); + UUID request=UUID.fromString(json.readTree(created.body()).get("id").asText());var response=EventAcceptanceIT.event(request,unique(),0);assertEquals(202,response.statusCode(),response.body()); + return UUID.fromString(json.readTree(response.body()).get("executionId").asText()); + } + static Token claim(String owner){return store.claim(owner,1,LEASE).getFirst();} + static void expire(Token token){admin.update("UPDATE "+schema+".execution SET lease_until=clock_timestamp()-interval '1 second' WHERE id=?",token.executionId().value());} + static String status(UUID id){return admin.queryForObject("SELECT status FROM "+schema+".execution WHERE id=?",String.class,id);} + static ObjectValue output(int score){return new ObjectValue(Map.of("score",new NumberValue(BigDecimal.valueOf(score))));} + static WorkerActions actions(Effect effect,java.util.function.BiFunction execute){return new WorkerActions(){ + public Optional capability(ActionDefinition definition){return Optional.of(new ActionCapability(definition.type(),effect,FlowFixtures.schema("{}"), + FlowFixtures.schema("{\"type\":\"object\",\"required\":[\"client\"],\"properties\":{\"client\":{\"type\":\"string\"}},\"additionalProperties\":false}"), + FlowFixtures.schema("{\"type\":\"object\",\"required\":[\"score\"],\"properties\":{\"score\":{\"type\":\"number\"}},\"additionalProperties\":false}")));} + public ActionResult execute(ActionDefinition definition,ActionContext context){return execute.apply(definition,context);} + };} + static WorkerEngine engine(WorkerActions actions){return new WorkerEngine(store,actions,FlowFixtures.CODEC);} + static ActionResult ok(int value){return new ActionResult.Completed(output(value),Optional.empty());} + static WorkerActions none(){return new WorkerActions(){public Optional capability(ActionDefinition d){return Optional.empty();}public ActionResult execute(ActionDefinition d,ActionContext c){throw new AssertionError("No invocation expected");}};} + static void lost(Runnable call){assertEquals(ErrorCode.LEASE_LOST,assertThrows(DomainException.class,call::run).code());} + static void await(CountDownLatch latch){try{assertTrue(latch.await(15,TimeUnit.SECONDS));}catch(InterruptedException e){Thread.currentThread().interrupt();throw new AssertionError(e);}} + + @Test void twoClaimantsHaveDisjointJobsAndLockedRowsAreSkipped()throws Exception{ + UUID locked=pending(flow(false),"{}"),other=pending(flow(false),"{}"); + try(var connection=admin.getDataSource().getConnection()){ + connection.setAutoCommit(false);try(var statement=connection.prepareStatement("SELECT id FROM "+schema+".execution WHERE id=? FOR UPDATE")){statement.setObject(1,locked);statement.executeQuery().close();} + var claimed=store.claim("worker-b",10,LEASE);assertEquals(List.of(other),claimed.stream().map(t->t.executionId().value()).toList());connection.rollback(); + } + assertEquals(locked,claim("worker-a").executionId().value()); + var ids=Set.of(pending(flow(false),"{}"),pending(flow(false),"{}"));var barrier=new CyclicBarrier(2); + try(var pool=Executors.newFixedThreadPool(2)){ + var a=pool.submit(()->{barrier.await();return store.claim("a",1,LEASE);});var b=pool.submit(()->{barrier.await();return fresh().claim("b",1,LEASE);}); + assertEquals(ids,Set.of(a.get(20,TimeUnit.SECONDS).getFirst().executionId().value(),b.get(20,TimeUnit.SECONDS).getFirst().executionId().value())); + } + } + @Test void renewalAndReclaimFenceEveryStaleMutation()throws Exception{ + UUID id=pending(flow(false),"{}");Token old=claim("old");var work=store.load(old); + store.renew(old,LEASE);expire(old);lost(()->store.renew(old,LEASE));Token current=claim("current");assertTrue(current.fence()>old.fence()); + var completion=new Completion(work.flowId(),"END",work.data(),new ObjectValue(Map.of("outcome",new StringValue("OK"),"fromState",new StringValue("START"),"toState",new StringValue("END"))),ObjectValue.empty()); + lost(()->store.load(old));lost(()->store.checkpoints(old));lost(()->store.retry(old,"action",ErrorCode.INTEGRATION_UNAVAILABLE,Duration.ofSeconds(2)));lost(()->store.start(old,"action","a".repeat(64),id+":action",Effect.READ_ONLY)); + lost(()->store.save(old,"action","a".repeat(64),output(1),Optional.empty()));lost(()->store.complete(old,completion));lost(()->store.fail(old,ErrorCode.ACTION_FAILED,false)); + engine(none()).run(current);assertEquals("SUCCEEDED",status(id)); + assertEquals(2L,admin.queryForObject("SELECT r.revision FROM "+schema+".request_instance r JOIN "+schema+".execution e ON e.request_id=r.id WHERE e.id=?",Long.class,id)); + } + @Test void durableCheckpointSurvivesAdapterRestartAndUsesStableExternalKey()throws Exception{ + var n=FlowFixtures.integration();n.put("flowKey",unique());var next=n.at("/transitions/0/actions/0").deepCopy();((com.fasterxml.jackson.databind.node.ObjectNode)next).put("id","second").put("resultKey","second");((com.fasterxml.jackson.databind.node.ArrayNode)n.at("/transitions/0/actions")).add(next); + UUID id=pending(publish(n),"{\"client\":\"C1\"}");var first=new AtomicInteger();var second=new AtomicInteger();var keys=new ArrayList(); + WorkerActions adapter=actions(Effect.READ_ONLY,(definition,context)->{if(definition.id().equals("lookup")){first.incrementAndGet();return ok(70);}keys.add(context.externalIdempotencyKey());if(second.incrementAndGet()==1)throw new SimulatedCrash();return ok(80);}); + Token old=claim("old");assertThrows(SimulatedCrash.class,()->engine(adapter).run(old));assertEquals(1,first.get()); + expire(old);Token recovered=claim("recovered");new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(recovered); + assertEquals("SUCCEEDED",status(id));assertEquals(1,first.get());assertEquals(2,second.get());assertEquals(keys.get(0),keys.get(1)); + assertEquals(2,admin.queryForObject("SELECT attempt_count FROM "+schema+".action_execution WHERE execution_id=? AND action_id='second'",Integer.class,id)); + } + static final class SimulatedCrash extends Error {} + @Test void unsafeInFlightEffectIsNeverRepeatedAndKeepsRequestBlocked()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var calls=new AtomicInteger();var adapter=actions(Effect.NON_IDEMPOTENT_WRITE,(a,c)->{calls.incrementAndGet();throw new SimulatedCrash();}); + Token old=claim("old");assertThrows(SimulatedCrash.class,()->engine(adapter).run(old));expire(old);engine(adapter).run(claim("new")); + assertEquals(1,calls.get());assertEquals("RECONCILIATION_REQUIRED",status(id)); + assertEquals("UNKNOWN",admin.queryForObject("SELECT status FROM "+schema+".action_execution WHERE execution_id=?",String.class,id)); + UUID request=admin.queryForObject("SELECT request_id FROM "+schema+".execution WHERE id=?",UUID.class,id);assertEquals(409,EventAcceptanceIT.event(request,unique(),1).statusCode()); + } + @Test void changedInputHashRefusesCheckpointReuse()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var calls=new AtomicInteger();Token old=claim("old"); + assertThrows(SimulatedCrash.class,()->engine(actions(Effect.READ_ONLY,(a,c)->{calls.incrementAndGet();throw new SimulatedCrash();})).run(old)); + admin.update("UPDATE "+schema+".request_instance SET data='{\"client\":\"DIFFERENT\"}'::jsonb WHERE id=?",old.requestId().value());expire(old); + engine(actions(Effect.READ_ONLY,(a,c)->{calls.incrementAndGet();return ok(2);})).run(claim("new"));assertEquals(1,calls.get());assertEquals("RECONCILIATION_REQUIRED",status(id)); + } + @Test void finalCommitFailureRollsBackAndRecoveryDoesNotRepeatSucceededAction()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var calls=new AtomicInteger();var adapter=actions(Effect.READ_ONLY,(a,c)->{calls.incrementAndGet();return ok(70);}); + admin.execute("CREATE FUNCTION "+schema+".fail_worker_finish() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='TRANSITION_COMPLETED' THEN RAISE EXCEPTION 'test final audit failure'; END IF; RETURN NEW; END; $$"); + admin.execute("CREATE TRIGGER test_worker_finish BEFORE INSERT ON "+schema+".audit_entry FOR EACH ROW EXECUTE PROCEDURE "+schema+".fail_worker_finish()"); + Token token=claim("a");try{assertThrows(org.springframework.dao.DataAccessException.class,()->engine(adapter).run(token));}finally{admin.execute("DROP TRIGGER test_worker_finish ON "+schema+".audit_entry");} + assertEquals("RUNNING",status(id));assertEquals("START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertEquals(1L,admin.queryForObject("SELECT revision FROM "+schema+".request_instance WHERE id=?",Long.class,token.requestId().value())); + expire(token);new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(claim("b"));assertEquals(1,calls.get());assertEquals("SUCCEEDED",status(id)); + } + @Test void oldWorkerReturningAfterNewWorkerCannotOverwriteResult()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var started=new CountDownLatch(1);var release=new CountDownLatch(1);Token old=claim("old"); + try(var pool=Executors.newSingleThreadExecutor()){ + var task=pool.submit(()->engine(actions(Effect.READ_ONLY,(a,c)->{started.countDown();await(release);return ok(1);})).run(old)); + await(started);expire(old);engine(actions(Effect.READ_ONLY,(a,c)->ok(99))).run(claim("new"));release.countDown();task.get(20,TimeUnit.SECONDS); + assertEquals("SUCCEEDED",status(id));assertEquals("99",admin.queryForObject("SELECT data->'result'->>'score' FROM "+schema+".request_instance WHERE id=?",String.class,old.requestId().value())); + }finally{release.countDown();} + } + @Test void scheduledLoopRenewsDuringActionAndStopsClaiming()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var started=new CountDownLatch(1);var renewed=new CountDownLatch(2);var release=new CountDownLatch(1);var finished=new CountDownLatch(1); + WorkerStore observed=(WorkerStore)java.lang.reflect.Proxy.newProxyInstance(WorkerStore.class.getClassLoader(),new Class[]{WorkerStore.class},(p,m,args)->{ + try{Object result=m.invoke(store,args);if(m.getName().equals("renew"))renewed.countDown();if(m.getName().equals("complete"))finished.countDown();return result;}catch(java.lang.reflect.InvocationTargetException e){throw e.getCause();}}); + var engine=new WorkerEngine(observed,actions(Effect.READ_ONLY,(a,c)->{started.countDown();await(release);return ok(50);}),FlowFixtures.CODEC); + var loop=new WorkerLoop(observed,engine,1,1,100,30,1); + try{loop.start();await(started);await(renewed);release.countDown();await(finished);}finally{release.countDown();loop.stop();} + assertFalse(loop.isRunning());assertEquals("SUCCEEDED",status(id));UUID pending=pending(flow(false),"{}");assertEquals("PENDING",status(pending)); + } + @Test void queryIsAuthorizedAndReturnsContractWithoutInternalSnapshots()throws Exception{ + UUID id=pending(flow(false),"{}");var before=call("GET","/api/v1/executions/"+id,RequestCreationIT.jwt(),null,null);assertEquals(200,before.statusCode());RequestCreationIT.contract(before,"ExecutionView"); + engine(none()).run(claim("worker"));var after=call("GET","/api/v1/executions/"+id,RequestCreationIT.jwt(),null,null);RequestCreationIT.contract(after,"ExecutionView"); + assertEquals("SUCCEEDED",json.readTree(after.body()).get("status").asText());assertEquals("OK",json.readTree(after.body()).at("/result/outcome").asText()); + assertFalse(after.body().contains("actor_snapshot"));assertFalse(after.body().contains("lease_owner")); + for(String jwt:List.of(token("ANALISTA","scope-b",""),token("FLOW_ADMIN","scope-a","otherowner")))assertEquals(404,call("GET","/api/v1/executions/"+id,jwt,null,null).statusCode()); + assertEquals(401,call("GET","/api/v1/executions/"+id,null,null,null).statusCode()); + } + @Test void missingAdaptersFailExplicitlyWithoutFabricatedSuccess()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");engine(none()).run(claim("worker"));assertEquals("FAILED",status(id)); + var response=call("GET","/api/v1/executions/"+id,RequestCreationIT.jwt(),null,null);RequestCreationIT.contract(response,"ExecutionView"); + assertEquals("INTEGRATION_UNAVAILABLE",json.readTree(response.body()).at("/error/code").asText()); + } + @Test void futureRetryIsNotClaimedUntilDatabaseDueTime()throws Exception{ + UUID id=pending(flow(false),"{}");admin.update("UPDATE "+schema+".execution SET status='RETRY_WAIT',next_attempt_at=clock_timestamp()+interval '1 hour' WHERE id=?",id); + assertTrue(store.claim("worker",10,LEASE).isEmpty()); + admin.update("UPDATE "+schema+".execution SET next_attempt_at=clock_timestamp()-interval '1 second' WHERE id=?",id); + assertEquals(id,claim("worker").executionId().value()); + } + @Test void idempotentWriteReusesProviderKeyAfterLostCheckpoint()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");var calls=new AtomicInteger();var effects=new AtomicInteger();var provider=new HashMap(); + var adapter=actions(Effect.IDEMPOTENT_WRITE,(a,c)->{var result=provider.computeIfAbsent(c.externalIdempotencyKey(),k->{effects.incrementAndGet();return ok(45);}); + if(calls.incrementAndGet()==1)throw new SimulatedCrash();return result;}); + Token old=claim("old");assertThrows(SimulatedCrash.class,()->engine(adapter).run(old));expire(old);new WorkerEngine(fresh(),adapter,FlowFixtures.CODEC).run(claim("new")); + assertEquals(2,calls.get());assertEquals(1,effects.get());assertEquals("SUCCEEDED",status(id)); + } + @Test void successfulWriteWithFailedCompletionRequiresReconciliation()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");Token token=claim("worker"); + engine(actions(Effect.IDEMPOTENT_WRITE,(a,c)->{admin.update("UPDATE "+schema+".request_instance SET revision=revision+1 WHERE id=?",token.requestId().value());return ok(70);})).run(token); + assertEquals("RECONCILIATION_REQUIRED",status(id));assertEquals("START",admin.queryForObject("SELECT current_state FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + } + @Test void staleRequestSnapshotIsRejectedBeforeCallingAdapter()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");Token token=claim("worker");var calls=new AtomicInteger(); + admin.update("UPDATE "+schema+".request_instance SET revision=revision+1 WHERE id=?",token.requestId().value()); + engine(actions(Effect.READ_ONLY,(a,c)->{calls.incrementAndGet();return ok(1);})).run(token); + assertEquals(0,calls.get());assertEquals("FAILED",status(id)); + } + @Test void decisionUpdatesAndAuditCommitTogether()throws Exception{ + var n=FlowFixtures.simple();n.put("flowKey",unique());((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/dataSchema/properties")).set("amount",FlowFixtures.node("{\"type\":\"number\"}")); + var transition=(com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0");transition.remove("to");transition.remove("outcome");transition.put("decision","CHOICE"); + transition.set("destinations",FlowFixtures.node("{\"HIGH\":\"END\",\"LOW\":\"END\"}")); + transition.set("updates",FlowFixtures.node("[{\"target\":\"/client\",\"value\":{\"literal\":\"done\"}}]")); + n.set("decisions",FlowFixtures.node("[{\"id\":\"CHOICE\",\"hitPolicy\":\"UNIQUE\",\"rules\":[{\"id\":\"high\",\"when\":{\"op\":\"GT\",\"left\":{\"path\":\"/request/data/amount\"},\"right\":{\"literal\":70}},\"output\":\"HIGH\"}],\"defaultOutput\":\"LOW\"}]")); + UUID id=pending(publish(n),"{\"client\":\"C1\",\"amount\":71}");Token token=claim("worker");engine(none()).run(token); + assertEquals("SUCCEEDED",status(id));assertEquals("done",admin.queryForObject("SELECT data->>'client' FROM "+schema+".request_instance WHERE id=?",String.class,token.requestId().value())); + assertEquals("HIGH",admin.queryForObject("SELECT result->>'outcome' FROM "+schema+".execution WHERE id=?",String.class,id)); + assertEquals("high",admin.queryForObject("SELECT detail->'ruleIds'->>0 FROM "+schema+".audit_entry WHERE execution_id=? AND kind='TRANSITION_COMPLETED'",String.class,id)); + } + @Test void oversizedFinalDataFailsInsteadOfRecoveringForever()throws Exception{ + var n=FlowFixtures.simple();n.put("flowKey",unique()); + var updates=json.createArrayNode();var update=updates.addObject();update.put("target","/client");update.putObject("value").put("literal","x".repeat(262144)); + ((com.fasterxml.jackson.databind.node.ObjectNode)n.at("/transitions/0")).set("updates",updates); + UUID id=pending(publish(n),"{}");engine(none()).run(claim("worker"));assertEquals("FAILED",status(id)); + assertEquals("SCHEMA_VALIDATION_ERROR",admin.queryForObject("SELECT error_code FROM "+schema+".execution WHERE id=?",String.class,id)); + } + @Test void completionRefusesMissingActionCheckpoint()throws Exception{ + UUID id=pending(flow(true),"{\"client\":\"C1\"}");Token token=claim("worker");var work=store.load(token); + var candidate=new Completion(work.flowId(),"END",work.data(),new ObjectValue(Map.of("outcome",new StringValue("OK"),"fromState",new StringValue("START"),"toState",new StringValue("END"))),ObjectValue.empty()); + assertEquals(ErrorCode.ACTION_FAILED,assertThrows(DomainException.class,()->store.complete(token,candidate)).code());assertEquals("RUNNING",status(id)); + assertEquals(1L,admin.queryForObject("SELECT revision FROM "+schema+".request_instance WHERE id=?",Long.class,token.requestId().value())); + engine(actions(Effect.READ_ONLY,(a,c)->ok(10))).run(token);assertEquals("SUCCEEDED",status(id)); + } +} diff --git a/src/test/java/com/organizacion/securitytest/ResourceProbe.java b/src/test/java/com/organizacion/securitytest/ResourceProbe.java new file mode 100644 index 0000000..7eab584 --- /dev/null +++ b/src/test/java/com/organizacion/securitytest/ResourceProbe.java @@ -0,0 +1,24 @@ +package com.organizacion.securitytest; + +import com.organizacion.orchestrator.shared.application.ResourceAccess; +import com.organizacion.orchestrator.shared.application.port.ResourceOwnershipStore.Kind; +import com.organizacion.orchestrator.shared.infrastructure.security.JwtActorMapper; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import java.util.*; + +/** Test source only, explicitly registered by SecurityHttpIT; never shipped or component-scanned by the application. */ +@RestController +public class ResourceProbe { + private final ResourceAccess access;private final JwtActorMapper actors; + public ResourceProbe(ResourceAccess access,JwtActorMapper actors){this.access=access;this.actors=actors;} + @GetMapping("/api/v1/test-resources/{kind}/{id}") + public Map read(@PathVariable Kind kind,@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + return Map.of("requestId",access.require(actors.actor(jwt),kind,id).requestId().value()); + } + @PostMapping("/api/v1/test-resources/resolve/{id}") + public Map resolve(@PathVariable UUID id,@AuthenticationPrincipal Jwt jwt){ + return Map.of("requestId",access.requireResolution(actors.actor(jwt),id).requestId().value()); + } +}