From 509bc0cde1388757c83e176aa013b221ef2e60a8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 18:35:28 -0300 Subject: [PATCH] =?UTF-8?q?F6.1=20=C2=B7=20cierre=20de=20presentaci=C3=B3n?= =?UTF-8?q?=20y=20barrera=20de=20release=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra F6.1 como candidata verificable de presentación: metadata WEB alineada, contratos transversales de aceptación, Android CI/RC endurecida con lint/tests/builds/checksum, correcciones de carreras de permisos GPS y cámara opcional, auditoría de dependencias productivas y parches de runtime, más documentación de release y recorrido de demo. --- .github/workflows/android.yml | 119 ++++++++++++---- .github/workflows/security-audit.yml | 48 +++++++ README.md | 4 +- android-app/RELEASE.md | 47 +++++++ android-app/app/src/main/AndroidManifest.xml | 1 + .../com/korexlabs/dhinspeccion/ui/DhApp.kt | 18 ++- .../korexlabs/dhinspeccion/ui/F3VisitRoot.kt | 22 +-- .../dhinspeccion/ui/FieldFindingScreen.kt | 18 ++- .../dhinspeccion/ui/MobileActsScreen.kt | 20 +-- .../dhinspeccion/ReleaseMetadataTest.kt | 20 +++ api-v3/package-lock.json | 16 +-- api-v3/package.json | 6 +- .../f6-1-presentation-ready-contract.test.ts | 66 +++++++++ docs/PRESENTACION_F6_1.md | 128 ++++++++++++++++++ web-v2/package-lock.json | 12 +- web-v2/package.json | 4 +- web-v2/src/config/version.ts | 4 +- 17 files changed, 478 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/security-audit.yml create mode 100644 android-app/RELEASE.md create mode 100644 android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt create mode 100644 api-v3/test/unit/f6-1-presentation-ready-contract.test.ts create mode 100644 docs/PRESENTACION_F6_1.md diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index aaa6c24..cd41d3d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,39 +1,36 @@ -name: Android APK -# F6.1: genera una APK debug verificable contra la API productiva F6.1. +name: Android CI / RC +# F6.1 presentation barrier: lint + real tests + debug artifact + release compile. on: push: branches: - 'main' - - 'release/f5-android-test' - - 'feature/f2-2*' - - 'feature/f2-3*' - - 'feature/f2-4*' - - 'feature/f3-1*' - - 'feature/f3-2*' - - 'feature/f6-1*' + - 'release/f6-1*' paths: - 'android-app/**' + - 'api-v3/src/**' - '.github/workflows/android.yml' pull_request: + branches: + - 'main' paths: - 'android-app/**' - - 'api-v3/src/auth/**' - - 'api-v3/src/asset-master/**' - - 'api-v3/src/inspection-visits/**' - - 'api-v3/src/inspection-acts/**' - - 'api-v3/src/inspection-findings/**' - - 'api-v3/src/inspection-verifications/**' + - 'api-v3/src/**' - '.github/workflows/android.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: dhv2-android-${{ github.ref }} + cancel-in-progress: true + jobs: - build-debug-apk: + android: + name: Android · lint, tests, debug APK, release compile runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 35 steps: - name: Checkout uses: actions/checkout@v4 @@ -55,18 +52,92 @@ jobs: with: gradle-version: '8.13' - - name: Assemble debug - working-directory: android-app - run: gradle --no-daemon :app:assembleDebug + - name: Validate mobile security and identity contract + run: | + set -Eeuo pipefail + grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts + grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts + grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts + grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml + grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml - - name: Unit tests + - name: Android lint + working-directory: android-app + run: gradle --no-daemon :app:lintDebug + + - name: Print complete lint failures + if: failure() + run: | + report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt" + if [ -f "$report" ]; then + echo '========== ANDROID LINT ==========' + cat "$report" + fi + + - name: Android unit tests working-directory: android-app run: gradle --no-daemon :app:testDebugUnitTest - - name: Upload APK + - name: Require real unit-test results + run: | + set -Eeuo pipefail + test_dir="android-app/app/build/test-results/testDebugUnitTest" + test -d "$test_dir" + total="$(grep -h -oE ']+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')" + test "$total" -gt 0 + echo "Android unit tests discovered: $total" + + - name: Assemble debug APK + working-directory: android-app + run: gradle --no-daemon :app:assembleDebug + + - name: Compile unsigned release variant + working-directory: android-app + run: gradle --no-daemon :app:assembleRelease + + - name: Package RC artifact and checksum + id: package + run: | + set -Eeuo pipefail + version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)" + code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)" + test -n "$version" + test -n "$code" + short_sha="${GITHUB_SHA::12}" + mkdir -p android-app/dist + apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk" + cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" + sha256sum "$apk" > "${apk}.sha256" + { + echo "phase=F6.1" + echo "version=$version" + echo "versionCode=$code" + echo "commit=$GITHUB_SHA" + echo "artifact=$(basename "$apk")" + echo "applicationId=com.korexlabs.dhinspeccion.debug" + echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/" + echo "channel=DEBUG_RC" + } > android-app/dist/release-metadata.txt + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "version_code=$code" >> "$GITHUB_OUTPUT" + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + + - name: Upload debug RC uses: actions/upload-artifact@v4 with: - name: DH-Inspeccion-F6.1-0.15.0-debug - path: android-app/app/build/outputs/apk/debug/app-debug.apk + name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug + path: android-app/dist/* if-no-files-found: error retention-days: 30 + + - name: Upload Android diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-diagnostics-${{ github.sha }} + path: | + android-app/app/build/reports/lint-results-debug.html + android-app/app/build/reports/tests/testDebugUnitTest/** + android-app/app/build/test-results/testDebugUnitTest/** + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..28b4a23 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,48 @@ +name: Production dependency audit + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dhv2-production-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + api: + name: API · production dependencies + runs-on: ubuntu-latest + defaults: + run: + working-directory: api-v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: api-v3/package-lock.json + - name: Reject high/critical runtime advisories + run: npm audit --omit=dev --audit-level=high + + web: + name: WEB · production dependencies + runs-on: ubuntu-latest + defaults: + run: + working-directory: web-v2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: web-v2/package-lock.json + - name: Reject high/critical runtime advisories + run: npm audit --omit=dev --audit-level=high diff --git a/README.md b/README.md index abca65f..8acc0b4 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ Repositorio del sistema DH Inspección V2. ## Documentación vigente +- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md) - [Manual del Programador](docs/MANUAL_PROGRAMADOR.md) - [Manual de Usuario](docs/MANUAL_USUARIO.md) - [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md) - [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md) +- [Contrato de release Android F6.1](android-app/RELEASE.md) ## Contratos que no deben romperse @@ -26,4 +28,4 @@ Repositorio del sistema DH Inspección V2. - La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend. - GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente. -Las reglas detalladas y el procedimiento de cambio seguro están en los manuales enlazados arriba. +Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba. diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md new file mode 100644 index 0000000..64e349d --- /dev/null +++ b/android-app/RELEASE.md @@ -0,0 +1,47 @@ +# DH Inspección Android · release F6.1 + +## Candidata vigente + +- Fase funcional: **F6.1**. +- `versionName`: **0.15.0**. +- `versionCode`: **22**. +- Application ID release: `com.korexlabs.dhinspeccion`. +- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. +- API: `https://dhv2.korexlabs.com/api/v3/`. + +La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. + +## Barrera obligatoria + +Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`: + +1. validación de identidad, HTTPS y políticas básicas del manifest; +2. Android lint; +3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada; +4. `assembleDebug`; +5. `assembleRelease` para comprobar que la variante productiva compile; +6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado. + +La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente. + +El artefacto de CI contiene: + +- APK debug; +- archivo `.sha256`; +- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal. + +## Firma release + +La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`. + +No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior. + +## Criterio de distribución + +Antes de distribuir una APK productiva: + +- todas las barreras de CI del SHA exacto deben estar verdes; +- comprobar certificado/huella de firma contra la versión histórica; +- realizar actualización sobre al menos una tablet con la versión productiva anterior; +- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre; +- registrar el SHA Git y SHA-256 de la APK distribuida. diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 73bfee0..9b26cae 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt index 424be1e..7cc4bd8 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhApp.kt @@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab } val source = CancellationTokenSource() val client = LocationServices.getFusedLocationProviderClient(context) - client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) - .addOnSuccessListener { location -> - if (!continuation.isActive) return@addOnSuccessListener - if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) - else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) - } - .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + try { + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) + else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } continuation.invokeOnCancellation { source.cancel() } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt index 2ca8670..7c28d8d 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt @@ -727,16 +727,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance } val source = CancellationTokenSource() val client = LocationServices.getFusedLocationProviderClient(context) - client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) - .addOnSuccessListener { location -> - if (!continuation.isActive) return@addOnSuccessListener - if (location == null) { - continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) - } else { - continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + try { + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) { + continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) + } else { + continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } } - } - .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } continuation.invokeOnCancellation { source.cancel() } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt index acc6c63..9b4b882 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt @@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su } val source = CancellationTokenSource() val client = LocationServices.getFusedLocationProviderClient(context) - client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) - .addOnSuccessListener { location -> - if (!continuation.isActive) return@addOnSuccessListener - if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) - else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) - } - .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + try { + client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) + else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } continuation.invokeOnCancellation { source.cancel() } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 2f2d3d9..d1d0749 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -431,13 +431,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = return@suspendCancellableCoroutine } val source = CancellationTokenSource() - LocationServices.getFusedLocationProviderClient(context) - .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) - .addOnSuccessListener { location -> - if (!continuation.isActive) return@addOnSuccessListener - if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) - else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) - } - .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + try { + LocationServices.getFusedLocationProviderClient(context) + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) + else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + } catch (error: SecurityException) { + if (continuation.isActive) continuation.resumeWithException(error) + } continuation.invokeOnCancellation { source.cancel() } } diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt new file mode 100644 index 0000000..96b87c7 --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -0,0 +1,20 @@ +package com.korexlabs.dhinspeccion + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReleaseMetadataTest { + @Test + fun debugBuildKeepsSeparateApplicationIdentity() { + assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) + assertEquals(22, BuildConfig.VERSION_CODE) + assertEquals("0.15.0-debug", BuildConfig.VERSION_NAME) + } + + @Test + fun fieldBuildTargetsOnlyTheHttpsProductionApi() { + assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL) + assertTrue(BuildConfig.API_BASE_URL.startsWith("https://")) + } +} diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 7438a4d..a491901 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.20.0-2", + "version": "0.29.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.20.0-2", + "version": "0.29.0-1", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", @@ -4166,9 +4166,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -4666,9 +4666,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/api-v3/package.json b/api-v3/package.json index 203bae3..119ab08 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -42,5 +42,9 @@ "ts-node": "^10.9.2", "tsx": "^4.20.6", "typescript": "^5.9.0" + }, + "overrides": { + "multer": "2.3.0", + "qs": "6.16.0" } -} \ No newline at end of file +} diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts new file mode 100644 index 0000000..329346a --- /dev/null +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8'); +const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8'); + +test('F6.1 presentation metadata keeps the visible WEB version aligned with package metadata', () => { + const pkg = JSON.parse(web('package.json')) as { version: string }; + const version = web('src/config/version.ts'); + const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; + + assert.equal(visibleVersion, pkg.version); + assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo Área–Operadora consolidado'/); +}); + +test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { + const app = web('src/app/App.tsx'); + const layout = web('src/layout/AppLayout.tsx'); + + assert.doesNotMatch(app, /relevamientos/i); + assert.doesNotMatch(layout, /relevamientos/i); + assert.match(layout, /label: 'Inspecciones'/); + assert.match(layout, /label: 'Inventarios'/); +}); + +test('F6.1 presentation keeps the complete Inspector profile and documentary-copy explanation visible', () => { + const user = web('src/pages/UserDetailPage.tsx'); + const delivery = api('src/inspection-reports/inspection-document-delivery.service.ts'); + + for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) { + assert.match(user, new RegExp(`name="${field}"`)); + } + assert.match(user, /email es obligatorio para un Inspector/i); + assert.match(user, /la documentación se enviará también/i); + assert.match(delivery, /recipientKind:'INSPECTOR'/); + assert.match(delivery, /documentKind:'ACT_PDF'/); + assert.match(delivery, /documentKind:'REPORT_WORD'/); +}); + +test('F6.1 presentation keeps Android start, Otro and chronological merge flows wired to the canonical API', () => { + const mobileApi = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt'); + const overview = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt'); + const viewModel = android('app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt'); + + assert.match(mobileApi, /inspection-visits\/\{id\}\/start/); + assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/); + assert.match(overview, /"Iniciar inspección"/); + assert.match(overview, /Otro \/ no catalogado/); + assert.match(viewModel, /repository\.startVisit\(id\)/); + assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/); + assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/); +}); + +test('F6.1 presentation keeps catalog merge append-only for emitted findings', () => { + const merge = api('src/inspection-findings/finding-catalog-merge.service.ts'); + const resolver = api('src/inspection-findings/f3-finding-catalog-resolver.service.ts'); + + assert.match(merge, /los Hallazgos emitidos nunca se reescriben/); + assert.match(merge, /historicalFindingsRewritten: false/); + assert.match(merge, /historyPolicy: 'EMITTED_FINDINGS_PRESERVED'/); + assert.match(resolver, /code: 'OTHER'/); + assert.match(resolver, /label: 'OTROS'/); +}); diff --git a/docs/PRESENTACION_F6_1.md b/docs/PRESENTACION_F6_1.md new file mode 100644 index 0000000..6e404df --- /dev/null +++ b/docs/PRESENTACION_F6_1.md @@ -0,0 +1,128 @@ +# DH Inspección V2 · corte de presentación F6.1 + +Este documento define el alcance verificable del corte F6.1 y el recorrido recomendado para una presentación funcional. No reemplaza al Manual de Usuario ni al Manual del Programador. + +## Versiones del corte + +- API: `0.29.0-1`. +- WEB: `0.23.0-1`. +- Android: `0.15.0` (`versionCode 22`). +- Modelo funcional: **F6.1 · contexto operativo Área–Operadora consolidado**. + +La versión visible en el footer WEB debe coincidir con `web-v2/package.json`; este contrato queda protegido por tests. + +## Matriz de aceptación funcional + +### Inventario + +- Jerarquía física vigente: **Departamento → Área → Yacimiento → Instalación → Subinstalación**. +- Empresa permanece fuera del árbol físico. +- La Operadora se resuelve por relación temporal vigente con el Área. +- Instalación y Subinstalación admiten familia **Otro / no catalogado** cuando corresponda. +- Un alta de campo conserva inspección, actor, fecha, contexto, GPS y evidencia exigida por el flujo. + +> El issue histórico F3.1 describía un árbol que comenzaba en Área. Esa definición fue supersedida por F5.1/F6, que incorporó Departamento como raíz territorial autorizada. + +### Merge cronológico de Inventario + +- Sólo se fusionan Instalaciones/Subinstalaciones compatibles. +- El registro duplicado no se elimina: queda fusionado/inactivo y conserva identidad histórica. +- Las referencias históricas emitidas no se reescriben. +- El dossier canónico agrega cronológicamente alias y eventos, mostrando el Inventario original cuando corresponde. +- WEB muestra el destino canónico. +- Android, al conciliar un alta nacida en campo, selecciona inmediatamente el canónico y confirma qué código se conserva. + +### Hallazgos y catálogo + +- `OTROS` permanece disponible en el resolver de Hallazgos. +- La fusión de catálogo mueve aplicabilidad futura al canónico sin reescribir Hallazgos ya emitidos. +- Las instancias históricas conservan el modelo append-only y la trazabilidad de auditoría. + +### Inspecciones y Android + +- Una inspección planificada puede iniciarse desde Android por un Inspector autorizado y asignado. +- El inicio registra `actualStartedAt`, actor y auditoría del servidor. +- El contexto operativo es Área + Operadora vigente. +- Una Inspección puede tener múltiples Actas, con un único borrador simultáneo. +- El cierre exige Actas selladas y verificaciones requeridas completas. + +### Perfil del Inspector + +- Nombre y apellido obligatorios. +- Datos administrativos disponibles: DNI, email, teléfono, cargo/función y legajo/matrícula. +- El email es obligatorio para rol Inspector. + +### Entrega documental + +- El Acta PDF se prepara para Empresa, Oficina e Inspector principal. +- El INF Word se prepara también para el Inspector principal cuando existe. +- El outbox conserva destinatario, estado, intentos, error y fecha de envío; los pendientes son reintentables. +- La entrega depende de SMTP y destinatarios configurados: si faltan, el registro queda en un estado de espera auditable en lugar de perderse. + +### Relevamientos + +- `Relevamientos` no existe como módulo/ruta/entrada de menú activa. +- La captura en campo vive dentro de Inspecciones + Inventario de campo. + +## Seguridad de dependencias del corte + +La candidata mantiene una barrera separada para dependencias productivas de API y WEB mediante `npm audit --omit=dev --audit-level=high`. + +Para F6.1 se fijaron las resoluciones parcheadas que eliminan los advisories detectados durante el cierre: + +- API: `multer 2.3.0` y `qs 6.16.0`. +- WEB: `maplibre-gl 6.4.1`. + +Las dependencias de runtime con vulnerabilidades altas o críticas bloquean el corte aunque typecheck, tests y builds estén verdes. + +## Barrera técnica obligatoria + +Un SHA sólo es candidato de presentación cuando pasan: + +1. API typecheck, tests y build. +2. WEB typecheck y build. +3. auditoría de dependencias productivas de API y WEB, sin advisories altos/críticos. +4. migraciones sobre PostGIS limpio. +5. arranque real de API. +6. preflight aislado equivalente al entorno Docker. +7. build de imágenes productivas. +8. Android lint. +9. Android unit tests reales. +10. Android `assembleDebug`. +11. Android `assembleRelease`. +12. APK debug con SHA-256 y metadata del SHA exacto. + +## Recorrido sugerido de demo + +1. **Login WEB** y Dashboard. +2. **Inventarios**: navegar Departamento → Área → Yacimiento → Instalación → Subinstalación y abrir un dossier. +3. Mostrar la **cronología/dossier**, documentos, fotos y Hallazgos. +4. Mostrar, si existe un caso preparado, una **fusión cronológica** y el aviso de registro canónico. +5. **Usuarios**: abrir un Inspector y mostrar perfil, email documental y roles. +6. **Inspecciones**: crear/abrir una planificación con Área + Operadora, Inspector y checklist. +7. **Android**: ingresar con Inspector, abrir o seleccionar la Inspección e iniciarla. +8. En Android, abrir **Inventario de campo**, seleccionar un registro o crear uno nuevo con GPS y foto. Para una familia no catalogada, usar **Otro / no catalogado**. +9. Crear un **Acta**, registrar un **Hallazgo** (incluyendo `OTROS` si se quiere demostrar el fallback), adjuntar evidencia y sellar el Acta. +10. Cerrar la Inspección cuando todas las Actas estén selladas. +11. En WEB, mostrar **Actas / Informes / Entrega documental** y el estado auditable de los envíos. + +## Preparación del entorno de presentación + +Antes de una demo con envío real de correo, verificar en Administración: + +- email institucional de Oficina; +- email de la Empresa involucrada; +- email del Inspector principal; +- SMTP configurado y operativo. + +Para una demo sin correo saliente, el resto del flujo puede demostrarse y la bandeja de entrega debe reflejar el estado de espera correspondiente; no se debe presentar un envío como exitoso si el transporte no está configurado. + +## Evidencia del corte + +El SHA presentado debe conservarse junto con: + +- resultado verde de GitHub Actions; +- auditoría productiva verde; +- metadata de versiones; +- APK debug de la candidata y su SHA-256; +- si se distribuye APK productiva, firma histórica verificada y SHA-256 del artefacto firmado. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index bf67230..8f15991 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,14 +1,14 @@ { "name": "dhv2-web", - "version": "0.20.0-2", + "version": "0.23.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.20.0-2", + "version": "0.23.0-1", "dependencies": { - "maplibre-gl": "^6.0.0", + "maplibre-gl": "6.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^8.0.0" @@ -1633,9 +1633,9 @@ } }, "node_modules/maplibre-gl": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz", - "integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz", + "integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "^1.1.0", diff --git a/web-v2/package.json b/web-v2/package.json index 594fc31..6a645db 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "maplibre-gl": "^6.0.0", + "maplibre-gl": "6.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^8.0.0" @@ -24,4 +24,4 @@ "typescript": "^5.9.0", "vite": "^7.0.0" } -} \ No newline at end of file +} diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index cf00036..5e328e5 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.21.0-1'; -export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado'; +export const APP_VERSION = '0.23.0-1'; +export const APP_PHASE = 'F6.1 · Contexto operativo Área–Operadora consolidado';