From dc545c9d866a611d160188b031e2bb2bd4448a89 Mon Sep 17 00:00:00 2001 From: soporte roganet Date: Tue, 29 Sep 2026 13:32:12 +0200 Subject: [PATCH] feat: incorporar eCMR y mercancias desde 7fbfd30 Traslada los cambios de 7fbfd300b4ea17212c04528145d366a3f00b91e5 conservando la configuracion de entorno de produccion. --- CARGO_API_APP_GUIDE.md | 173 +++++++++++++++++ ECMR_API_APP_GUIDE.md | 134 +++++++++++++ package-lock.json | 207 +++++++++++++++++++- package.json | 1 + src/controllers/ecmrController.js | 124 ++++++++++++ src/controllers/tripCargoController.js | 80 ++++++++ src/routes/tripsRoutes.js | 7 + src/services/tripCargoWriter.js | 20 ++ test/trips.cargo.integration.test.js | 112 +++++++++++ test/trips.ecmr.integration.test.js | 254 +++++++++++++++++++++++++ 10 files changed, 1110 insertions(+), 2 deletions(-) create mode 100644 CARGO_API_APP_GUIDE.md create mode 100644 ECMR_API_APP_GUIDE.md create mode 100644 src/controllers/ecmrController.js create mode 100644 src/controllers/tripCargoController.js create mode 100644 src/services/tripCargoWriter.js create mode 100644 test/trips.cargo.integration.test.js create mode 100644 test/trips.ecmr.integration.test.js diff --git a/CARGO_API_APP_GUIDE.md b/CARGO_API_APP_GUIDE.md new file mode 100644 index 0000000..55c0f78 --- /dev/null +++ b/CARGO_API_APP_GUIDE.md @@ -0,0 +1,173 @@ +# Mercancía del viaje desde la app + +Base: la misma URL de API usada por el login y los viajes. +Todas las peticiones requieren `Authorization: Bearer `. +POST y PUT requieren `Content-Type: application/json`. + +Solo el conductor asignado al viaje puede consultar, añadir o editar su +mercancía. La mercancía pertenece al viaje completo y la comparten sus tramos. +Las operaciones se limitan al identificador de viaje solicitado. + +## Consultar mercancía y tipos de embalaje + +`GET /api/trips/:id/cargo` + +```json +{ + "success": true, + "data": { + "mercancias": [ + { + "id_mercancia": 123, + "id_tipo_embalaje": 1, + "cantidad": 2, + "peso": 400, + "largo": 120, + "ancho": 80, + "alto": 150, + "remontable": true, + "tipo_embalaje": "Palet", + "tipo_embalaje_en": "Pallet" + } + ], + "embalajes": [ + { "id_tipo_embalaje": 1, "tipo_embalaje": "Palet", "tipo_embalaje_en": "Pallet" } + ], + "observaciones_mercancia": "Material", + "ecmr_emitido": true + } +} +``` + +Los nombres de embalaje del ejemplo son ilustrativos: usar los valores e IDs +que devuelva el servidor. `embalajes` contiene las opciones activas. +`observaciones_mercancia` es información del viaje, de solo lectura en estos +endpoints. Una lista `mercancias: []` permite añadir la primera línea. + +## Añadir una línea + +`POST /api/trips/:id/cargo` + +```json +{ + "id_tipo_embalaje": 1, + "cantidad": 2, + "peso": 400, + "largo": 120, + "ancho": 80, + "alto": 150, + "remontable": true +} +``` + +## Editar una línea + +`PUT /api/trips/:id/cargo/:cargoId` + +Mismo cuerpo que POST. `cargoId` es el `id_mercancia` devuelto por GET. +Enviar la línea completa; no es una actualización parcial. La línea debe +pertenecer al viaje indicado. Las demás líneas se conservan. + +| Campo | Regla | +|---|---| +| `id_tipo_embalaje` | Entero obligatorio; debe existir y estar activo. En edición se permite conservar el embalaje anterior aunque esté inactivo. | +| `cantidad` | Entero obligatorio mayor que cero; número de bultos. | +| `peso` | Entero obligatorio, de 0 a 9999999999; **peso total de la línea en kg**, no peso por bulto. | +| `largo`, `ancho`, `alto` | Enteros en centímetros, entre 0 y 2147483647. Si se omiten se guardan a cero. | +| `remontable` | Booleano; también se acepta 0/1. Si se omite se guarda como falso. | + +Los números deben enviarse como números JSON, no cadenas. El almacenamiento +actual no admite decimales en peso o medidas; no redondear silenciosamente. +Para emitir un eCMR el peso total del viaje debe ser mayor que cero. + +## Respuesta de guardado + +POST devuelve HTTP 201; PUT devuelve HTTP 200. + +```json +{ + "success": true, + "data": { + "id_mercancia": 123, + "id_tipo_embalaje": 1, + "cantidad": 2, + "peso": 400, + "largo": 120, + "ancho": 80, + "alto": 150, + "remontable": true, + "tipo_embalaje": "Palet", + "tipo_embalaje_en": "Pallet" + }, + "ecmr_documentos_actualizados": 1, + "ecmr_versiones": [ + { + "id_documento": 45, + "numero": "ECMR-VIAJE-P1-V3", + "version": 3, + "public_url": "https:///adm/pages/logic/ecmr/documento.php?t=", + "n_proveedor": 1 + } + ] +} +``` + +Al añadir o editar se genera una nueva versión y un nuevo PDF de **cada eCMR +vigente del viaje**. Se conservan los extras documentales (remolque, ADR, +aduanas, autorización especial e Incoterm). Las versiones y firmas antiguas +permanecen en el historial. Las nuevas versiones requieren nuevas firmas; +los enlaces de firma de versiones sustituidas dejan de ser válidos. + +`ecmr_documentos_actualizados` cuenta todas las revisiones del viaje. +`ecmr_versiones` solo expone los documentos de los tramos asignados al conductor. +Si todavía no hay eCMR, la mercancía se guarda y se devuelve 0 y `[]`; la +primera emisión continúa haciéndose desde el ERP. + +El guardado y las revisiones se realizan en una misma transacción. Si faltan +datos para emitir o falla un PDF, no se confirma el cambio de mercancía. +Se marca el viaje como editado desde la app. No se envían invitaciones por +correo o WhatsApp automáticamente en esta operación. + +## Flujo recomendado del frontend + +1. Abrir «Mercancía» desde el detalle del viaje y cargar GET. +2. Mostrar líneas y botones «Añadir mercancía» / «Editar». +3. Usar los embalajes recibidos y convertir los campos numéricos antes de enviar. +4. Si `ecmr_emitido` es verdadero, avisar antes de guardar: «Se generará una + nueva versión del eCMR y será necesario firmarla de nuevo». +5. Deshabilitar Guardar durante la petición; no reenviar automáticamente POST + ante un timeout, para evitar duplicar líneas. Consultar el estado primero. +6. Tras el éxito, refrescar mercancía y `GET /api/trips/:id/ecmr` para obtener + los nuevos PDFs y enlaces de firma. Desechar enlaces antiguos. +7. Mostrar los errores conservando los datos del formulario para corregirlos. + +## Errores + +Formato: `{ "success": false, "error": "..." }`. Una validación de campo +puede añadir `field`, por ejemplo `"field": "peso"`. + +- 401: sesión ausente, inválida o sin DNI. Usar el flujo de login existente. +- 403: el conductor no tiene asignado el viaje. +- 404: viaje o línea inexistente, o línea de otro viaje. +- 422: datos inválidos, embalaje no permitido o datos insuficientes para emitir + el eCMR. El cambio no se ha guardado. +- 500: fallo al guardar o generar documentos. No presentar éxito ni suponer + que no existen mercancías. + +No existe operación de eliminación en esta integración. + +## Operación del backend + +Node invoca un proceso PHP local, sin shell y con JSON por stdin, para reutilizar +el generador del ERP y mantener una única transacción de mercancía/documentos. +El script solo funciona en CLI, vuelve a comprobar la asignación y exige que +la base de datos coincida con `DB_NAME` de Node. No admite acceso HTTP. +La ruta del generador está en `src/services/tripCargoWriter.js`; esta instalación +usa `/var/www/desarrollogestion/desarrollo/adm/pages/logic/ecmr/mercancia_app_cli.php`. + +No se necesitan migraciones. El usuario del proceso Node debe poder ejecutar +PHP, leer el ERP y escribir los PDFs en su repositorio eCMR. + +Validación: 73 pruebas Node y prueba de integración PHP sobre desarrollo con +altas, ediciones, PDF real, versiones, permisos, fallo de emisión y rollback. +Los datos y PDFs de prueba se revierten/eliminan. diff --git a/ECMR_API_APP_GUIDE.md b/ECMR_API_APP_GUIDE.md new file mode 100644 index 0000000..d7fca5a --- /dev/null +++ b/ECMR_API_APP_GUIDE.md @@ -0,0 +1,134 @@ +# Consulta y firma eCMR desde la app + +`GET /api/trips/:id/ecmr` + +Enviar `Authorization: Bearer ` con la sesión de la app. +La respuesta contiene únicamente documentos activos con PDF generado de los +tramos cuyo DNI coincide con el conductor autenticado. + +```json +{ + "success": true, + "data": [ + { + "id_documento": 7, + "n_proveedor": 2, + "numero": "ECMR-123", + "version": 1, + "estado": "emitido", + "fecha_creacion": "2026-09-16 10:00:00", + "document_url": "https:///adm/pages/logic/ecmr/documento.php?t=", + "firma_carga_completada": false, + "firma_descarga_completada": false, + "firma_carga_url": "https:///adm/pages/logic/ecmr/firmar.php?t=", + "firma_descarga_url": "https:///adm/pages/logic/ecmr/firmar.php?t=", + "firma_expira_en_segundos": 900 + } + ] +} +``` + +## Integración React / React Native + +En el detalle del viaje, consultar esta ruta y mostrar: + +- «Ver eCMR»: abrir `document_url` mediante el visor PDF de la app. +- «Firmar carga»: abrir `firma_carga_url` en el navegador seguro o WebView con + soporte HTTPS y WebCrypto. Si es `null`, mostrar «Carga firmada». +- «Firmar descarga»: abrir `firma_descarga_url` de la misma manera. Si es `null`, + mostrar «Descarga firmada». + +Los enlaces son independientes por documento y rol. Después de volver de la +pantalla de firma, consultar de nuevo la ruta para refrescar el estado. Los +campos `firma_*_completada` son booleanos; no inferir las firmas a partir del +estado general del viaje. + +Las URLs de firma duran 15 minutos desde su emisión. Volver a consultar cuando +caduquen. Cada consulta crea invitaciones temporales para los roles pendientes: +no usar sondeo periódico ni precarga masiva. La respuesta lleva `no-store`. +No persistir ni registrar enlaces, y no enviar el Bearer de la app a las URLs +PHP. El token del enlace es la credencial temporal, no un parámetro `skip_otp`. + +La pantalla PHP valida el acceso emitido por Node, comprueba que el conductor +continúa asignado al tramo y omite el código de seis cifras. El DNI procede de +la sesión y no se puede cambiar. Se mantienen nombre, consentimiento y firma +criptográfica. Las invitaciones de correo/WhatsApp conservan su OTP. +Tras firmar carga desde la app, se consulta la app para acceder a la firma de +descarga; no se envía un correo o WhatsApp automático en este canal. + +Si `data` está vacío, mostrar «Este viaje todavía no tiene eCMR disponible». +El PDF se entrega como `attachment`; abrirlo en un navegador puede descargarlo. + +Errores: 401 sin sesión/DNI, 404 identificador inválido o viaje inexistente, +403 conductor no asignado, 500 error de base de datos/invitaciones. No mostrar +los errores como ausencia de documentos. Un enlace PHP caducado o cuyo tramo +ya no está asignado devuelve 403 y pide volver a abrirlo desde la app. + +## Requisitos + +- Node y PHP deben usar las mismas tablas `ecmr_documentos`, `ecmr_invitaciones`, + `ecmr_firmas` y `c_viajes_proveedor`. +- Desplegar también `firma_app.php`, los cambios de `firma_api.php`, `firmar.php`, + `common.php` y las traducciones ES/EN del ERP. +- `public_url` debe apuntar al ERP actualizado y ser accesible mediante HTTPS. +- No requiere nuevas columnas ni migraciones para los enlaces de firma. +- No se ha facilitado el código React; queda pendiente conectar sus botones. + +## Validación + +- 40 pruebas Node: consulta eCMR, autorización, enlaces por rol, rollback y + regresión de listado, viaje activo y puntos intermedios. +- 27 comprobaciones PHP: DNI, caducidad, asignación, OTP y traducciones. +- Prueba del JavaScript de firma para sesiones app y enlaces con OTP. +- Consultas SQL reales e invitación temporal verificadas en desarrollo mediante + una transacción revertida. No se ha firmado ningún documento real. + +## QR del PDF DeCA / eCMR + +`GET /api/trips/:id/ecmr/:documentId/qr` + +Usar el mismo `Authorization: Bearer ` y el `id_documento` +obtenido en la consulta eCMR del viaje. Se especifica el documento porque un +viaje puede tener varios tramos y versiones. + +```json +{ + "success": true, + "data": { + "id_documento": 7, + "n_proveedor": 2, + "numero": "ECMR-123", + "version": 1, + "document_url": "https:///adm/pages/logic/ecmr/documento.php?t=", + "qr_data_url": "data:image/png;base64,..." + } +} +``` + +La imagen PNG es de 320 × 320 píxeles y codifica exactamente `document_url`, +la URL del PDF usada por el QR del documento. No codifica un enlace de firma. +En React Native puede mostrarse con: + +```jsx + +``` + +Solo devuelve documentos activos con PDF generado y del tramo asignado al DNI +de la sesión. Responde `401` sin sesión/DNI y `404` si los identificadores no son +válidos, el documento no existe, pertenece a otro viaje/conductor, está sustituido +o no tiene PDF generado. El `404` evita revelar documentos ajenos. Los fallos de +BD o generación del QR devuelven `500` con un mensaje genérico. + +La respuesta lleva `Cache-Control: no-store`. Esta consulta no crea invitaciones, +no modifica la BD ni envía mensajes; se puede usar para actualizar la imagen sin +renovar enlaces de firma. Al sustituirse una versión, volver a consultar los +documentos del viaje y utilizar el nuevo identificador. El QR contiene un enlace +público al PDF: no registrarlo en logs ni analítica. + +Despliegue: instalar las dependencias del lockfile (`npm ci --omit=dev`) y +reiniciar el proceso Node según el procedimiento habitual. Se añade `qrcode` +1.5.4. No requiere migraciones de BD ni cambios en PHP. + +Pruebas: `node --test test/trips.ecmr.integration.test.js` cubre autenticación, +identificadores, filtrado por tramo/conductor, ausencia de documentos, PNG real, +URL codificada, errores de BD/generador y regresión del flujo de firma. diff --git a/package-lock.json b/package-lock.json index 754f4ac..aa875e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "multer": "^2.0.2", "mysql2": "^3.9.7", "nodemailer": "^6.10.1", + "qrcode": "1.5.4", "ssh2-sftp-client": "^12.0.1" } }, @@ -814,6 +815,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -985,6 +995,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1022,6 +1041,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1356,6 +1381,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/firebase-admin": { "version": "13.7.0", "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", @@ -1599,7 +1637,6 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", - "optional": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -2129,6 +2166,18 @@ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -2534,6 +2583,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2549,6 +2634,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2586,6 +2680,15 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", @@ -2637,6 +2740,89 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -2695,11 +2881,16 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -2916,6 +3107,12 @@ "node": ">= 0.8" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3433,6 +3630,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 118d657..025333e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "multer": "^2.0.2", "mysql2": "^3.9.7", "nodemailer": "^6.10.1", + "qrcode": "1.5.4", "ssh2-sftp-client": "^12.0.1" } } diff --git a/src/controllers/ecmrController.js b/src/controllers/ecmrController.js new file mode 100644 index 0000000..c9317fe --- /dev/null +++ b/src/controllers/ecmrController.js @@ -0,0 +1,124 @@ +const db = require('../config/db'); +const crypto = require('crypto'); +const QRCode = require('qrcode'); + +const getTripEcmr = async (req, res) => { + const dni = req.user?.dni; + const tripId = Number(req.params.id); + + res.set('Cache-Control', 'no-store'); + if (!dni) { + return res.status(401).json({ error: 'Unauthorized' }); + } + if (!/^\d+$/.test(req.params.id) || !Number.isSafeInteger(tripId) || tripId <= 0) { + return res.status(404).json({ success: false, error: 'Trip not found' }); + } + + let connection; + try { + const [trips] = await db.query( + 'SELECT id_viaje FROM c_viajes WHERE id_viaje = ? LIMIT 1', + [tripId] + ); + if (trips.length === 0) { + return res.status(404).json({ success: false, error: 'Trip not found' }); + } + const [assignments] = await db.query( + 'SELECT 1 FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1', + [tripId, dni] + ); + if (assignments.length === 0) { + return res.status(403).json({ success: false, error: 'Forbidden' }); + } + connection = await db.getConnection(); + await connection.beginTransaction(); + const [documents] = await connection.query( + `SELECT d.id_documento, d.n_proveedor, d.numero, d.version, + d.estado, d.fecha_creacion, d.public_url AS document_url, + EXISTS(SELECT 1 FROM ecmr_firmas f WHERE f.id_documento = d.id_documento + AND f.rol_firmante = 'toma_carga') AS firma_carga_completada, + EXISTS(SELECT 1 FROM ecmr_firmas f WHERE f.id_documento = d.id_documento + AND f.rol_firmante = 'entrega') AS firma_descarga_completada + FROM ecmr_documentos d + INNER JOIN c_viajes_proveedor p + ON p.id_viaje = d.id_viaje AND p.n_proveedor = d.n_proveedor + WHERE d.id_viaje = ? AND p.dni = ? AND d.activo = 1 + AND d.pdf_ruta IS NOT NULL AND d.pdf_ruta <> '' + ORDER BY d.n_proveedor ASC FOR UPDATE`, + [tripId, dni] + ); + for (const document of documents) { + document.firma_carga_completada = Boolean(Number(document.firma_carga_completada)); + document.firma_descarga_completada = Boolean(Number(document.firma_descarga_completada)); + document.firma_carga_url = null; + document.firma_descarga_url = null; + document.firma_expira_en_segundos = 900; + for (const [role, field, signed] of [ + ['toma_carga', 'firma_carga_url', document.firma_carga_completada], + ['entrega', 'firma_descarga_url', document.firma_descarga_completada] + ]) { + if (signed) continue; + const url = new URL('firmar.php', document.document_url); + if (url.protocol !== 'https:') throw new Error('eCMR signing requires HTTPS'); + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const challenge = crypto.randomBytes(32).toString('hex'); + await connection.query( + `INSERT INTO ecmr_invitaciones + (id_documento, rol_firmante, canal, destino, email, token_hash, + challenge, estado, fecha_creacion) + VALUES (?, ?, 'app', ?, NULL, ?, ?, 'pendiente', NOW())`, + [document.id_documento, role, dni, tokenHash, challenge] + ); + url.searchParams.set('t', token); + document[field] = url.toString(); + } + } + await connection.commit(); + return res.json({ success: true, data: documents }); + } catch (error) { + if (connection) await connection.rollback(); + console.error('Error fetching trip eCMR:', error.code || 'ECMR_QUERY_FAILED'); + return res.status(500).json({ success: false, error: 'Could not load eCMR documents' }); + } finally { + if (connection) connection.release(); + } +}; + +const getTripEcmrQr = async (req, res) => { + const dni = req.user?.dni; + const tripId = Number(req.params.id); + const documentId = Number(req.params.documentId); + res.set('Cache-Control', 'no-store'); + if (!dni) return res.status(401).json({ error: 'Unauthorized' }); + if (![req.params.id, req.params.documentId].every(value => /^\d+$/.test(value) + && Number.isSafeInteger(Number(value)) && Number(value) > 0)) { + return res.status(404).json({ success: false, error: 'eCMR document not found' }); + } + try { + const [documents] = await db.query( + `SELECT d.id_documento, d.n_proveedor, d.numero, d.version, d.public_url AS document_url + FROM ecmr_documentos d + INNER JOIN c_viajes_proveedor p + ON p.id_viaje = d.id_viaje AND p.n_proveedor = d.n_proveedor + WHERE d.id_viaje = ? AND d.id_documento = ? AND p.dni = ? AND d.activo = 1 + AND d.pdf_ruta IS NOT NULL AND d.pdf_ruta <> '' + LIMIT 1`, + [tripId, documentId, dni] + ); + if (documents.length === 0) { + return res.status(404).json({ success: false, error: 'eCMR document not found' }); + } + const document = documents[0]; + if (new URL(document.document_url).protocol !== 'https:') throw new Error('eCMR QR requires HTTPS'); + const qrDataUrl = await QRCode.toDataURL(document.document_url, { + type: 'image/png', errorCorrectionLevel: 'M', margin: 4, width: 320 + }); + return res.json({ success: true, data: { ...document, qr_data_url: qrDataUrl } }); + } catch (error) { + console.error('Error fetching trip eCMR QR:', error.code || 'ECMR_QR_FAILED'); + return res.status(500).json({ success: false, error: 'Could not load eCMR QR' }); + } +}; + +module.exports = { getTripEcmr, getTripEcmrQr }; diff --git a/src/controllers/tripCargoController.js b/src/controllers/tripCargoController.js new file mode 100644 index 0000000..9518f3d --- /dev/null +++ b/src/controllers/tripCargoController.js @@ -0,0 +1,80 @@ +const db = require('../config/db'); +const cargoWriter = require('../services/tripCargoWriter'); + +const validId = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 && Number(value) <= 2147483647; + +const authorizeTrip = async (queryable, tripId, dni) => { + const [trips] = await queryable.query( + `SELECT id_viaje, observaciones_mercancia FROM c_viajes WHERE id_viaje = ?`, + [tripId] + ); + if (!trips.length) return { status: 404, error: 'Trip not found' }; + const [assigned] = await queryable.query( + `SELECT n_proveedor FROM c_viajes_proveedor WHERE id_viaje = ? AND dni = ? LIMIT 1`, + [tripId, dni] + ); + if (!assigned.length) return { status: 403, error: 'Forbidden' }; + return { trip: trips[0] }; +}; + +const cargoSelect = `SELECT m.id_mercancia, m.id_tipo_embalaje, m.cantidad, m.peso, + m.largo, m.ancho, m.alto, m.remontable, e.tipo_embalaje, e.tipo_embalaje_en + FROM c_viajes_mercancia m LEFT JOIN t_embalaje e ON e.id_tipo_embalaje = m.id_tipo_embalaje`; + +const getTripCargo = async (req, res) => { + res.set('Cache-Control', 'no-store'); + if (!req.user?.dni) return res.status(401).json({ error: 'Unauthorized' }); + if (!validId(req.params.id)) return res.status(404).json({ success: false, error: 'Trip not found' }); + const tripId = Number(req.params.id); + try { + const access = await authorizeTrip(db, tripId, req.user.dni); + if (access.error) return res.status(access.status).json({ success: false, error: access.error }); + const [mercancias] = await db.query(`${cargoSelect} WHERE m.id_viaje = ? ORDER BY m.id_mercancia`, [tripId]); + const [embalajes] = await db.query('SELECT id_tipo_embalaje, tipo_embalaje, tipo_embalaje_en FROM t_embalaje WHERE activo = 1 ORDER BY id_tipo_embalaje'); + const [documents] = await db.query('SELECT id_documento FROM ecmr_documentos WHERE id_viaje = ? AND activo = 1 LIMIT 1', [tripId]); + return res.json({ success: true, data: { + mercancias: mercancias.map(row => ({ ...row, peso: Number(row.peso), remontable: Boolean(Number(row.remontable)) })), embalajes, + observaciones_mercancia: access.trip.observaciones_mercancia || '', + ecmr_emitido: documents.length > 0 + } }); + } catch (error) { + console.error('Error fetching trip cargo:', error.code || 'CARGO_QUERY_FAILED'); + return res.status(500).json({ success: false, error: 'Could not load trip cargo' }); + } +}; + +const saveTripCargo = async (req, res) => { + res.set('Cache-Control', 'no-store'); + if (!req.user?.dni) return res.status(401).json({ error: 'Unauthorized' }); + if (!validId(req.params.id)) return res.status(404).json({ success: false, error: 'Trip not found' }); + const editing = req.params.cargoId !== undefined; + if (editing && !validId(req.params.cargoId)) return res.status(404).json({ success: false, error: 'Cargo not found' }); + const body = req.body || {}; + const values = {}; + for (const [field, min, max] of [ + ['id_tipo_embalaje', 1, 2147483647], ['cantidad', 1, 2147483647], + ['peso', 0, 9999999999], ['largo', 0, 2147483647], + ['ancho', 0, 2147483647], ['alto', 0, 2147483647] + ]) { + const value = body[field] === undefined && ['largo', 'ancho', 'alto'].includes(field) ? 0 : body[field]; + if (!Number.isSafeInteger(value) || value < min || value > max) { + return res.status(422).json({ success: false, error: `Invalid ${field}`, field }); + } + values[field] = value; + } + const remontable = body.remontable === undefined ? false : body.remontable; + if (![true, false, 0, 1].includes(remontable)) return res.status(422).json({ success: false, error: 'Invalid remontable', field: 'remontable' }); + values.remontable = Number(remontable); + try { + const result = await cargoWriter.save({ + trip_id: Number(req.params.id), cargo_id: editing ? Number(req.params.cargoId) : null, + dni: req.user.dni, mercancia: values, database: process.env.DB_NAME + }); + return res.status(result.status).json(result.body); + } catch (error) { + console.error('Error saving trip cargo:', error.code || 'CARGO_SAVE_FAILED'); + return res.status(500).json({ success: false, error: 'Could not save cargo and generate eCMR' }); + } +}; + +module.exports = { getTripCargo, saveTripCargo }; diff --git a/src/routes/tripsRoutes.js b/src/routes/tripsRoutes.js index d885e00..0806161 100644 --- a/src/routes/tripsRoutes.js +++ b/src/routes/tripsRoutes.js @@ -1,5 +1,7 @@ const express = require('express'); const tripsController = require('../controllers/tripsController'); +const { getTripEcmr, getTripEcmrQr } = require('../controllers/ecmrController'); +const { getTripCargo, saveTripCargo } = require('../controllers/tripCargoController'); const requireBearerAuth = require('../middleware/requireBearerAuth'); const { uploadTripStatusPhotos } = require('../middleware/tripStatusUpload'); @@ -8,6 +10,11 @@ const router = express.Router(); router.get('/trips/states', requireBearerAuth, tripsController.getTripStates); router.get('/trips/active', requireBearerAuth, tripsController.getActiveTrip); router.get('/trips', requireBearerAuth, tripsController.getTrips); +router.get('/trips/:id/cargo', requireBearerAuth, getTripCargo); +router.post('/trips/:id/cargo', requireBearerAuth, saveTripCargo); +router.put('/trips/:id/cargo/:cargoId', requireBearerAuth, saveTripCargo); +router.get('/trips/:id/ecmr', requireBearerAuth, getTripEcmr); +router.get('/trips/:id/ecmr/:documentId/qr', requireBearerAuth, getTripEcmrQr); router.get('/trips/:id/intermediate-points', requireBearerAuth, tripsController.getIntermediatePoints); router.post('/trips/:id/intermediate-points/:pointId/status', requireBearerAuth, tripsController.updateIntermediatePointStatus); router.get('/trips/:id/status-history', requireBearerAuth, tripsController.getTripStatusHistory); diff --git a/src/services/tripCargoWriter.js b/src/services/tripCargoWriter.js new file mode 100644 index 0000000..eaccbff --- /dev/null +++ b/src/services/tripCargoWriter.js @@ -0,0 +1,20 @@ +const { execFile } = require('child_process'); + +const script = '/var/www/desarrollogestion/desarrollo/adm/pages/logic/ecmr/mercancia_app_cli.php'; + +const save = payload => new Promise((resolve, reject) => { + const child = execFile('/usr/bin/php', [script], { timeout: 60000, maxBuffer: 1024 * 1024 }, (error, stdout) => { + if (error) return reject(error); + try { + const result = JSON.parse(stdout); + if (![200, 201, 403, 404, 422, 500].includes(result.status) || typeof result.body?.success !== 'boolean') { + throw new Error('Invalid cargo service response'); + } + resolve(result); + } catch (parseError) { reject(parseError); } + }); + child.stdin.on('error', reject); + child.stdin.end(JSON.stringify(payload)); +}); + +module.exports = { save }; diff --git a/test/trips.cargo.integration.test.js b/test/trips.cargo.integration.test.js new file mode 100644 index 0000000..60e8ba5 --- /dev/null +++ b/test/trips.cargo.integration.test.js @@ -0,0 +1,112 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const db = require('../src/config/db'); +const writer = require('../src/services/tripCargoWriter'); +const routes = require('../src/routes/tripsRoutes'); +const originalQuery = db.query; +const originalSave = writer.save; +process.env.JWT_SECRET = 'cargo-test-secret'; +let server; +let base; +const valid = { id_tipo_embalaje: 1, cantidad: 2, peso: 400, largo: 120, ancho: 80, alto: 150, remontable: true }; + +test.before(async () => { + const app = express(); app.use(express.json()); app.use('/api', routes); + await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); }); + base = `http://127.0.0.1:${server.address().port}/api/trips`; +}); +test.afterEach(() => { db.query = originalQuery; writer.save = originalSave; }); +test.after(async () => { await new Promise(resolve => server.close(resolve)); }); +const request = async (method = 'GET', suffix = '/123/cargo', body, identity = { dni: '12345678A' }) => { + const headers = { 'Content-Type': 'application/json' }; + if (identity) headers.Authorization = `Bearer ${jwt.sign(identity, process.env.JWT_SECRET)}`; + const response = await fetch(base + suffix, { method, headers, body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body) }); + return { status: response.status, body: await response.json() }; +}; +for (const method of ['GET', 'POST', 'PUT']) { + for (const identity of [null, { id: 1 }]) { + test(`${method} requires authenticated driver ${JSON.stringify(identity)}`, async () => { + db.query = writer.save = async () => assert.fail('Must not access data'); + assert.equal((await request(method, method === 'PUT' ? '/123/cargo/7' : '/123/cargo', valid, identity)).status, 401); + }); + } +} +for (const id of ['0', '-1', '123abc', '2147483648']) { + test(`invalid trip ${id}`, async () => { + writer.save = async () => assert.fail('Must not save'); + assert.equal((await request('POST', `/${id}/cargo`, valid)).status, 404); + }); +} +test('unknown trip and unauthorized driver cannot read cargo', async () => { + db.query = async () => [[]]; + assert.equal((await request()).status, 404); + let calls = 0; + db.query = async (sql, params) => { + calls++; + if (calls === 1) return [[{ id_viaje: 123 }]]; + assert.deepEqual(params, [123, '12345678A']); + return [[]]; + }; + assert.equal((await request()).status, 403); + assert.equal(calls, 2); +}); +test('reads cargo, active packaging and document status', async () => { + let calls = 0; + db.query = async (sql, params) => { + calls++; + if (calls === 1) return [[{ id_viaje: 123, observaciones_mercancia: 'Material' }]]; + if (calls === 2) return [[{ n_proveedor: 1 }]]; + if (calls === 3) { assert.deepEqual(params, [123]); return [[{ id_mercancia: 7, peso: '400' }]]; } + if (calls === 4) { assert.match(sql, /activo = 1/); return [[{ id_tipo_embalaje: 1 }]]; } + assert.deepEqual(params, [123]); return [[{ id_documento: 8 }]]; + }; + const response = await request(); + assert.equal(response.status, 200); + assert.equal(response.body.data.ecmr_emitido, true); + assert.equal(response.body.data.mercancias[0].id_mercancia, 7); + assert.equal(response.body.data.observaciones_mercancia, 'Material'); +}); +for (const [field, value] of [ + ['cantidad', 0], ['cantidad', 1.5], ['cantidad', '2'], ['cantidad', null], + ['peso', -1], ['peso', 1.5], ['peso', 10000000000], ['peso', null], + ['largo', -10], ['ancho', 2147483648], ['alto', 0.5], + ['remontable', 'true'], ['remontable', 2], ['id_tipo_embalaje', 0] +]) { + test(`rejects invalid ${field}=${value}`, async () => { + writer.save = async () => assert.fail('Must not save invalid cargo'); + const response = await request('POST', '/123/cargo', { ...valid, [field]: value }); + assert.equal(response.status, 422); + assert.equal(response.body.field, field); + }); +} +for (const editing of [false, true]) { + test(`saves ${editing ? 'edited' : 'new'} cargo using server identity and new eCMR versions`, async () => { + writer.save = async payload => { + assert.equal(payload.dni, '12345678A'); + assert.equal(payload.trip_id, 123); + assert.equal(payload.cargo_id, editing ? 7 : null); + assert.equal(payload.mercancia.remontable, 1); + assert.equal(payload.database, process.env.DB_NAME); + return { status: editing ? 200 : 201, body: { success: true, data: { id_mercancia: 7 }, ecmr_versiones: [{ id_documento: 9, version: 2 }] } }; + }; + const response = await request(editing ? 'PUT' : 'POST', editing ? '/123/cargo/7' : '/123/cargo', { ...valid, dni: 'forged', trip_id: 999 }); + assert.equal(response.status, editing ? 200 : 201); + assert.equal(response.body.ecmr_versiones[0].version, 2); + }); +} +for (const status of [403, 404, 422, 500]) { + test(`propagates transaction failure ${status}`, async () => { + writer.save = async () => ({ status, body: { success: false, error: 'Failed' } }); + const response = await request('POST', '/123/cargo', valid); + assert.equal(response.status, status); + assert.equal(response.body.success, false); + }); +} +test('process failures return 500 without exposing internals', async () => { + writer.save = async () => { throw new Error('/private/internal'); }; + const response = await request('POST', '/123/cargo', valid); + assert.equal(response.status, 500); + assert.doesNotMatch(response.body.error, /private/); +}); diff --git a/test/trips.ecmr.integration.test.js b/test/trips.ecmr.integration.test.js new file mode 100644 index 0000000..4d3ef07 --- /dev/null +++ b/test/trips.ecmr.integration.test.js @@ -0,0 +1,254 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const crypto = require('node:crypto'); +const test = require('node:test'); +const jwt = require('jsonwebtoken'); +const express = require('express'); +const db = require('../src/config/db'); +const routes = require('../src/routes/tripsRoutes'); + +process.env.JWT_SECRET = 'ecmr-test-secret'; +const originalQuery = db.query; +const originalGetConnection = db.getConnection; +let transaction; +let server; +let port; + +test.before(async () => { + const app = express(); + app.use('/api', routes); + server = http.createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = server.address().port; +}); +test.beforeEach(() => { + transaction = { committed: false, rolledBack: false, released: false }; + db.getConnection = async () => ({ + beginTransaction: async () => {}, + query: (...args) => db.query(...args), + commit: async () => { transaction.committed = true; }, + rollback: async () => { transaction.rolledBack = true; }, + release: () => { transaction.released = true; } + }); +}); +test.after(async () => { + db.getConnection = originalGetConnection; + db.query = originalQuery; + await new Promise(resolve => server.close(resolve)); +}); +test.afterEach(() => { db.query = originalQuery; }); + +const request = async (id = '123', payload = { dni: '12345678A' }) => { + const headers = payload === null ? {} : { + authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}` + }; + const response = await fetch(`http://127.0.0.1:${port}/api/trips/${id}/ecmr`, { headers }); + return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') }; +}; + +for (const payload of [null, { id: 1 }]) { + test(`rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => { + db.query = async () => { assert.fail('Must not query database'); }; + assert.equal((await request('123', payload)).status, 401); + }); +} +for (const id of ['0', '-1', '123abc', '1.5', '9007199254740992']) { + test(`rejects invalid trip id ${id}`, async () => { + db.query = async () => { assert.fail('Must not query database'); }; + assert.equal((await request(id)).status, 404); + }); +} +test('returns 404 for unknown trip', async () => { + db.query = async () => [[]]; + assert.equal((await request()).status, 404); +}); +test('does not retrieve documents for another driver', async () => { + let calls = 0; + db.query = async (sql, params) => { + calls++; + if (calls === 1) return [[{ id_viaje: 123 }]]; + assert.equal(calls, 2); + assert.match(sql, /dni = \?/); + assert.deepEqual(params, [123, '12345678A']); + return [[]]; + }; + assert.equal((await request()).status, 403); + assert.equal(calls, 2); +}); +for (const [loadSigned, unloadSigned] of [[0, 0], [1, 0], [0, 1], [1, 1]]) { + test(`returns links only for unsigned roles (${loadSigned}, ${unloadSigned})`, async () => { + const inserted = []; + let calls = 0; + db.query = async (sql, params) => { + calls++; + if (calls === 1) return [[{ id_viaje: 123 }]]; + if (calls === 2) return [[{ authorized: 1 }]]; + if (calls === 3) { + assert.deepEqual(params, [123, '12345678A']); + assert.match(sql, /p\.n_proveedor = d\.n_proveedor/); + assert.match(sql, /p\.dni = \?/); + assert.match(sql, /d\.activo = 1/); + assert.match(sql, /d\.pdf_ruta IS NOT NULL/); + assert.match(sql, /FOR UPDATE/); + assert.match(sql, /ecmr_firmas/); + return [[{ id_documento: 7, document_url: 'https://example.test/ecmr/documento.php?t=pdf-token', + firma_carga_completada: loadSigned, firma_descarga_completada: unloadSigned }]]; + } + assert.match(sql, /INSERT INTO ecmr_invitaciones/); + assert.match(sql, /'app'/); + assert.match(sql, /NOW\(\)/); + assert.equal(params[0], 7); + assert.equal(params[2], '12345678A'); + assert.match(params[3], /^[a-f0-9]{64}$/); + assert.match(params[4], /^[a-f0-9]{64}$/); + inserted.push(params); + return [{ insertId: 100 + calls }]; + }; + const response = await request(); + assert.equal(response.status, 200); + assert.equal(response.cache, 'no-store'); + const document = response.body.data[0]; + assert.equal(document.firma_carga_completada, Boolean(loadSigned)); + assert.equal(document.firma_descarga_completada, Boolean(unloadSigned)); + assert.equal(document.firma_expira_en_segundos, 900); + const tokens = []; + for (const [role, field, signed] of [ + ['toma_carga', 'firma_carga_url', loadSigned], + ['entrega', 'firma_descarga_url', unloadSigned] + ]) { + if (signed) { assert.equal(document[field], null); continue; } + const url = new URL(document[field]); + assert.equal(url.origin + url.pathname, 'https://example.test/ecmr/firmar.php'); + const token = url.searchParams.get('t'); + assert.match(token, /^[a-f0-9]{64}$/); + tokens.push(token); + const row = inserted.find(params => params[1] === role); + assert.equal(row[3], crypto.createHash('sha256').update(token).digest('hex')); + assert.notEqual(row[4], token); + } + assert.equal(new Set(tokens).size, tokens.length); + assert.equal(inserted.length, 2 - loadSigned - unloadSigned); + assert.deepEqual(transaction, { committed: true, rolledBack: false, released: true }); + }); +} +test('returns an empty list without creating invitations', async () => { + let calls = 0; + db.query = async () => { + calls++; + assert.ok(calls <= 3); + return calls < 3 ? [[{ authorized: 1 }]] : [[]]; + }; + const response = await request(); + assert.equal(response.status, 200); + assert.deepEqual(response.body.data, []); + assert.equal(transaction.committed, true); +}); +test('rolls back all links if an invitation fails', async () => { + let calls = 0; + db.query = async () => { + calls++; + if (calls < 3) return [[{ authorized: 1 }]]; + if (calls === 3) return [[{ id_documento: 7, document_url: 'https://example.test/documento.php' }]]; + if (calls === 4) return [{ insertId: 1 }]; + throw new Error('forced insert failure'); + }; + const response = await request(); + assert.equal(response.status, 500); + assert.equal(response.body.success, false); + assert.deepEqual(transaction, { committed: false, rolledBack: true, released: true }); +}); +test('rejects signing URLs without HTTPS', async () => { + let calls = 0; + db.query = async () => { + calls++; + assert.ok(calls <= 3); + if (calls < 3) return [[{ authorized: 1 }]]; + return [[{ id_documento: 7, document_url: 'http://example.test/documento.php' }]]; + }; + assert.equal((await request()).status, 500); + assert.equal(transaction.rolledBack, true); +}); +test('database errors do not expose internal details', async () => { + db.query = async () => { throw new Error('internal database detail'); }; + const response = await request(); + assert.equal(response.status, 500); + assert.equal(response.body.error, 'Could not load eCMR documents'); +}); + +const requestQr = async (tripId = '123', documentId = '7', payload = { dni: '12345678A' }) => { + const headers = payload === null ? {} : { authorization: `Bearer ${jwt.sign(payload, process.env.JWT_SECRET)}` }; + const response = await fetch(`http://127.0.0.1:${port}/api/trips/${tripId}/ecmr/${documentId}/qr`, { headers }); + return { status: response.status, body: await response.json(), cache: response.headers.get('cache-control') }; +}; +for (const payload of [null, { id: 1 }]) { + test(`QR rejects missing authentication or driver: ${JSON.stringify(payload)}`, async () => { + db.query = async () => assert.fail('Must not query database'); + assert.equal((await requestQr('123', '7', payload)).status, 401); + }); +} +for (const invalid of ['0', '-1', '7abc', '1.5', '9007199254740992']) { + test(`QR rejects invalid identifiers: ${invalid}`, async () => { + db.query = async () => assert.fail('Must not query database'); + assert.equal((await requestQr(invalid, '7')).status, 404); + assert.equal((await requestQr('123', invalid)).status, 404); + }); +} +test('QR returns only the requested active document of the assigned driver and creates no invitations', async () => { + const QRCode = require('qrcode'); + const originalEncode = QRCode.toDataURL; + const url = 'https://example.test/ecmr/documento.php?t=' + 'a'.repeat(64); + let encoded; + QRCode.toDataURL = async (text, options) => { encoded = text; return originalEncode(text, options); }; + try { + db.getConnection = async () => assert.fail('QR must not create signing invitations'); + let queries = 0; + db.query = async (sql, params) => { + queries++; + assert.match(sql, /^SELECT/); + assert.match(sql, /p\.n_proveedor = d\.n_proveedor/); + assert.match(sql, /d\.id_viaje = \? AND d\.id_documento = \? AND p\.dni = \? AND d\.activo = 1/); + assert.match(sql, /d\.pdf_ruta IS NOT NULL AND d\.pdf_ruta <> ''/); + assert.deepEqual(params, [123, 7, '12345678A']); + return [[{ id_documento: 7, n_proveedor: 2, numero: 'ECMR-123', version: 3, document_url: url }]]; + }; + const response = await requestQr(); + assert.equal(response.status, 200); + assert.equal(response.cache, 'no-store'); + assert.equal(response.body.data.id_documento, 7); + assert.equal(response.body.data.version, 3); + assert.equal(response.body.data.document_url, url); + assert.equal(encoded, url); + assert.equal(queries, 1); + assert.match(response.body.data.qr_data_url, /^data:image\/png;base64,/); + const png = Buffer.from(response.body.data.qr_data_url.split(',')[1], 'base64'); + assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); + assert.equal(png.readUInt32BE(16), 320); + assert.equal(png.readUInt32BE(20), 320); + assert.equal(response.body.data.firma_carga_url, undefined); + } finally { QRCode.toDataURL = originalEncode; } +}); +test('QR returns 404 when no authorized active PDF matches', async () => { + db.query = async () => [[]]; + const response = await requestQr(); + assert.equal(response.status, 404); + assert.equal(response.cache, 'no-store'); + assert.equal(response.body.data, undefined); +}); +for (const url of ['http://example.test/pdf', 'not-a-url', '']) { + test(`QR rejects invalid PDF URL ${url}`, async () => { + db.query = async () => [[{ document_url: url }]]; + assert.equal((await requestQr()).status, 500); + }); +} +test('QR handles database and image generation errors without leaking URLs or internals', async () => { + db.query = async () => { throw new Error('private database details'); }; + assert.deepEqual((await requestQr()).body, { success: false, error: 'Could not load eCMR QR' }); + const QRCode = require('qrcode'), originalEncode = QRCode.toDataURL; + try { + db.query = async () => [[{ document_url: 'https://example.test/documento.php?t=private' }]]; + QRCode.toDataURL = async () => { throw new Error('private QR details'); }; + const response = await requestQr(); + assert.equal(response.status, 500); + assert.deepEqual(response.body, { success: false, error: 'Could not load eCMR QR' }); + } finally { QRCode.toDataURL = originalEncode; } +});