# Resultado — 003-B3: correcciones de la revisión, validación en tres capas y portada pública

Commit en `/home/criss/proyectos/vecinal` sobre `11aedb2`. `bin/pruebas` pasa entero
(74 pruebas, 1873 aserciones). Los datos reales de Criss —operadora «Dassi», junta
«Las Princesas»— siguen intactos; comprobado por SQL antes y después de cada
reconstrucción del contenedor.

## 1. La contraseña en claro (hallazgo 2)

**Cómo se demostró que ya no aparece en la página de error**, con una prueba que
fuerza el fallo de verdad, no razonándolo (`tests/Unitario/Acceso/Dominio/DatosSensiblesEnTrazaTest.php`):

- Se llama a `HasheadorDeContrasenasArgon2id::hashear()` de verdad (Argon2id sobre
  libsodium, sin doble) con una contraseña de 5000 bytes, forzando la
  `InvalidPasswordException` real de `SodiumPasswordHasher`. Se inspecciona
  `$excepcion->getTrace()` (el array — `getTraceAsString()` no imprime argumentos
  nunca, con o sin el atributo, así que probar sobre esa cadena no demuestra nada) y
  se comprueba que el valor no aparece y que en su lugar hay un objeto
  `SensitiveParameterValue`.
- La prueba fuerza `zend.exception_ignore_args=0` con `ini_set()` en `setUp()` a
  propósito: así se ejercita la defensa que depende de la firma de la función
  (`#[\SensitiveParameter]`), no la de configuración (`zend.exception_ignore_args=1`
  en `php-prod.ini`, ya comprobada aparte dentro del contenedor). Las dos son
  independientes y las dos están puestas.
- **Hallazgo propio, a mitad de esta tarea**: la primera versión de la prueba del
  token de instalación (mismo archivo) SÍ dejaba el token en claro en la traza, pese
  a que `AlmacenDeTokenDeInstalacionEnArchivo::guardar()` ya llevaba el atributo.
  Motivo: el fallo real ocurre dentro de `file_put_contents()`, una función nativa
  que no se puede marcar — `#[\SensitiveParameter]` protege el marco de `guardar()`,
  no el de la función nativa que llama por debajo. Se corrigió `guardar()` para que
  nunca deje burbujear la excepción de esa función nativa (la suprime con `@` y
  comprueba el resultado a mano, lanzando su propia excepción con la ruta como
  argumento, nunca el token). Detalle completo en la pieza de pizarra.
- `zend.exception_ignore_args=1` / `display_errors=0` en el contenedor levantado sin
  perfil de desarrollo: comprobado con `php -i` dentro del contenedor (no leyendo el
  archivo).
- `APP_ENV` vale `prod` por defecto (`compose.yaml` + `.env`, ver más abajo la
  decisión) — comprobado con `docker compose config` y dentro del contenedor.
- Tope superior de contraseña: `Contrasena::MAXIMO = 256` (objeto de valor nuevo en
  `src/Acceso/Dominio/Contrasena.php`), muy por debajo de los 4096 bytes donde
  `SodiumPasswordHasher::hash()` lanzaría excepción — así que ese camino ya no se
  alcanza por HTTP. Probado en asistente y panel
  (`tests/Funcional/Acceso/ContrasenaEnormeTest.php`): un POST con 5000 bytes de
  contraseña da 200 con mensaje de validación, nunca 500.
- `#[\SensitiveParameter]` en toda la cadena: `HasheadorDeContrasenas` (interfaz e
  implementación), los cinco `Comando` que cargan una contraseña o el token de
  instalación, `AlmacenDeTokenDeInstalacion::guardar()`, y los campos de contraseña
  de los `Solicitud*` que validan los formularios.
- **Regla de CONVENCIONES.md, corregida por Criss a mitad de tarea**: diagnóstico
  (página de error, trazas, consola, validación, bitácora, sonda) nunca en claro;
  presentación (pantallas que pide un usuario autorizado) sí, RUT y nombre incluidos
  — solo la contraseña es la excepción sin matices. Reescrita en `CONVENCIONES.md`.

## 2. Validación en tres capas

- **Servidor**: `Contrasena` (mínimo 12, máximo 256, lista de comunes) y `Correo`
  (formato, máximo 255) nuevos en el dominio; `Rut` ya existía. Una clase
  `Solicitud*` por formulario (`src/Acceso/Infraestructura/Web/Solicitud/`,
  `src/Portada/Infraestructura/Web/Solicitud/`) con restricciones del validador de
  Symfony, y `ValidadorDeSolicitud` (reubicado a `src/Comun/Validacion/`: es
  infraestructura genérica sin nada específico de Acceso, así que Portada también
  la usa sin cruzar la frontera de contexto) reduce las violaciones a
  `array<campo, mensaje>`. Los manejadores (`InstalarEcosistemaManejador`,
  `CrearRevendedorManejador`, `CrearJuntaManejador`, `CrearUsuarioManejador`) vuelven
  a validar con los objetos de valor antes de tocar nada — no confían en que el
  controlador ya lo hiciera.
- **HTML**: `minlength`/`maxlength` de contraseña y correo, y el `pattern` de RUT,
  salen de `constant('App\\...\\Contrasena::MINIMO')` etc. en la plantilla — cambiar
  el número en el dominio cambia el HTML sin tocar ningún `.twig` (es la forma más
  simple que encontré de cumplir "se derivan, no se escriben a mano" sin construir
  un mapeador de metadatos del validador completo; lo dejo anotado como decisión a
  revisar si en algún momento hace falta algo más elaborado).
- **JavaScript**: `assets/controladores/contrasena.js`, aviso de caracteres que
  faltan al escribir. Nada de Stimulus: no está instalado en el proyecto pese a lo
  que decía `CONVENCIONES.md` desde 003-B1 — corregido ahí también.
- Ningún mensaje de validación repite el valor rechazado: los de `Rut` y
  `CorreoDuplicado` sí lo hacían (hallazgo no señalado explícitamente en la revisión,
  pero lo pide el criterio de aceptación sin condición) — corregidos.
- Probado con `curl` (sin navegador): cuerpo vacío en los cinco formularios da error
  de validación y no crea/modifica nada
  (`tests/Funcional/Acceso/FormulariosVaciosTest.php`, más
  `InstalacionAsistenteTest.php` ya existente para el asistente completo).

## 3. CSRF en el panel

Los tres formularios (`panel_tenant_editar`, `panel_revendedor_crear`,
`panel_junta_crear`) llevan token, comprobado con `isCsrfTokenValid()` igual que ya
hacía el paso 5 del asistente. Un POST sin token da error y no crea nada, probado por
ruta (`tests/Funcional/Acceso/CsrfPanelTest.php`).

## 4. La portada pública

`GET /` (contexto nuevo `Portada`, misma forma de tres capas que Acceso) reemplaza la
página de Symfony. Cifras agregadas (`ConsultaCifrasPublicas`, SQL directo contra
`tenant` — lectura, no importación de clase, no cruza la frontera) con umbral de
privacidad aplicado con `HAVING` en el propio SQL: una comuna por debajo de 5 juntas
nunca sale de la base. Destacados (`Destacado`, migración
`Version20260828030000.php`) con consentimiento obligatorio para publicar, en dos
sitios como el resto de invariantes del proyecto: `Destacado::publicar()` y un
`CHECK` en la base. Gestión desde `/panel/destacados`.

**Decisión a revisar**: la autorización de `/panel/destacados` y `/panel/salud`
pregunta por el nombre de cadena (`denyAccessUnlessGranted('ecosistema.configurar')`
/ `'sistema.ver.salud'`) en vez de importar `Capacidad`/`ComprobadorDeCapacidad` de
Acceso — es el mecanismo que ya traía `VotanteDeCapacidad::supports()` (acepta
cualquier cadena), y es lo que permite que Portada y `Comun/Salud` pidan una
autorización que solo Acceso sabe resolver sin importar una sola clase de Acceso.
Reutiliza `ecosistema.configurar` para destacados en vez de crear una capacidad
nueva — encaja semánticamente y evita ampliar el modelo de permisos para esto.

**No verificado con un navegador real**: no hay Chromium/headless ni `pip`
disponibles en este entorno, y `docker pull` de cualquier imagen con headless-browser
(probé `zenika/alpine-chrome`, `chromedp/headless-shell`, varios intentos) falla de
forma reproducible con un error TLS de red — detalle en la pieza de pizarra. Lo que
sí comprobé: no hay ningún ancho fijo en píxeles en la plantilla (`grep` de
`w-\[...px\]`), la estructura reutiliza el mismo patrón (`flex-col`, `px-4`,
`max-w-xl`) ya usado y aceptado en las plantillas del panel. **Pido a Criss que
confirme visualmente en un móvil o con las herramientas de desarrollo del navegador**
— es el único punto de esta tarea que no llegué a demostrar como pide el brief.

## 5 y 6. `/instalacion/entorno` fuera, salud con tres superficies

`src/Controlador/` y `src/Servicio/` eliminados. La comprobación se mudó a
`src/Comun/Salud/ComprobadorEntorno` (más `comprobarEspacioEnDisco()`, nuevo). Tres
consumidores:

- **Sonda** (`vecinal:salud:sonda`), dentro del contenedor, sin ruta HTTP —
  `compose.yaml` la usa como `healthcheck` de `aplicacion`. Comprobado de verdad:
  paré `base` con `docker compose stop base`, `aplicacion` pasó a `unhealthy` en
  `docker compose ps` en menos de un minuto, la sonda imprimió `no-sano` con código
  de salida 1 (sin versión, extensión ni mensaje de error), y al reiniciar `base`
  volvió a `healthy`. Los datos de Criss siguieron intactos durante la prueba.
- **Panel de salud** (`/panel/salud`), autenticado, capacidad `sistema.ver.salud`
  nueva (techo solo operadora, cualquier rol — es de solo lectura, como
  `bitacora.ver`; **decisión a revisar** si Criss prefiere restringirla a
  titular/administrador).
- **Paso 1 del asistente**, sin cambios de comportamiento, solo de espacio de
  nombres.

`deptrac --fail-on-uncovered` da `Violations 0, Uncovered 0` (antes: `Uncovered 170`).
Hizo falta una capa `Vendor` nueva en `deptrac.php` con `ClassNameRegexConfig` — sí
funciona sin escanear `vendor/` (contradice lo anotado en la pieza de pizarra de
003-B2; el problema era la sintaxis del patrón, detalle en mi pieza). Demostrado que
la prueba de fronteras sigue fallando de verdad: inyecté una dependencia real de
`Tenant.php` (dominio) hacia `UsuarioSeguridad` (infraestructura), `deptrac analyse`
dio `Violations 1`, se deshizo. `testNingunContextoImportaClasesDeOtro` ahora cubre
cualquier directorio de `src/` salvo `Comun` (antes exigía que tuviera `Dominio/`,
que es justo cómo se coló el hallazgo 9).

## 7, 8, 9 — el resto de hallazgos

- **7**: `tenant.editar.propio` autorizaba también sobre descendientes; movida a la
  lista "debe ser exactamente el propio" en `ServicioAutorizacion`. Prueba nueva:
  `ServicioAutorizacionTest::testTenantEditarPropioNoAutorizaSobreUnDescendiente`.
- **8**: las dos pruebas de `PermisosTest.php` renombradas a lo que de verdad
  comprueban (`testUnAdministradorSiPuedeEditarElTenantPorRuta`,
  `testUnLectorNoPuedeEscribirEnNingunaRutaDeEscrituraDelPanel`, esta última ampliada
  a las tres rutas de escritura). No hay ruta HTTP para transferir titularidad ni
  nombrar administradores (la primera fuera de alcance por decisión de Criss, la
  segunda sin pantalla): esas dos capacidades ya estaban bien comprobadas, por
  servicio, en `ServicioAutorizacionTest`.
- **9**: `AlmacenDeTokenDeInstalacionEnArchivo::guardar()` crea el archivo ya en
  `0600` (baja el `umask` del proceso antes de escribir, no hay ventana de un
  segundo paso).

## Fuera de alcance, respetado

No toqué el índice único ni los disparadores de titular (hallazgo 1). No construí
cifrado de datos personales ni padrón de socios. No instalé Node. No usé `sudo`; el
único comando que necesitaba root (`chown` de `var/` tras ejecutar algo como root vía
`docker compose exec`) lo até a un hecho de entorno documentado en la pizarra, no a
un script nuevo en `bin/`.

## Qué no llegué a comprobar

- El viewport móvil de la portada con un navegador real (ver arriba).
- No escribí una prueba dedicada que confirme literalmente "`APP_ENV` vale `prod` por
  defecto" vía PHPUnit — lo comprobé a mano (`docker compose config`, `php -i` dentro
  del contenedor) porque es un hecho de `compose.yaml`/`.env`, no de la aplicación.
