feat(auditoría visual): añadir scripts y documentación para auditorías visuales autenticadas con Playwright MCP
This commit is contained in:
parent
27f428d114
commit
9b3363198c
@ -359,6 +359,8 @@ Al finalizar:
|
||||
- El servidor utiliza Docker, stdio y la imagen oficial fijada por digest. No instalar Chromium en WSL, publicar puertos MCP ni crear una configuración global duplicada.
|
||||
- Confirmar la URL anunciada por Vite y el puerto real antes de navegar. Con la red host configurada, utilizar el mismo origen local que Metis; no sustituirlo por el puerto de la API ni por `host.docker.internal` sin comprobar Vite y APP_ORIGIN.
|
||||
- Para comprobaciones reproducibles usar una API y datos de prueba; toda prueba con persistencia utiliza exclusivamente `metis_desk_test`. No crear usuarios, guardar formularios ni modificar datos reales como parte de una auditoría visual sin autorización específica.
|
||||
- Para inspecciones autenticadas, utilizar `python3 scripts/visual-audit.py`: prepara una instalación desechable en otro contenedor, accede por el formulario real con un usuario ordinario y proporciona una sesión privada para el lanzador de Codex. No ejecutar las fixtures destructivas de integración para preparar la auditoría ni conectar este procedimiento a una base existente. El administrador de preparación solo existe durante la vida del contenedor temporal.
|
||||
- Mantener el proceso de auditoría abierto mientras Codex utiliza su sesión. Pasar `METIS_PLAYWRIGHT_STORAGE_STATE` únicamente al lanzador documentado; no copiar cookies, contraseñas ni `storageState` a mensajes, configuración global, código o Git. Al terminar, cerrar el proceso para retirar los servicios temporales y borrar la sesión. `--check` verifica el recorrido y limpia automáticamente.
|
||||
- Comprobar teclado, foco, estados de carga/error/vacío, tamaños estrechos y amplios y contenido extenso según el cambio. Guardar evidencia local en `.artifacts/playwright/` y mantenerla fuera de Git; no guardar credenciales ni estados de sesión en el repositorio.
|
||||
- Abrir e inspeccionar las capturas antes de afirmar una revisión visual. Una imagen PNG válida, un snapshot accesible y una prueba funcional son evidencias distintas; indicar qué se ha ejecutado realmente.
|
||||
- Si Docker, la aplicación o el MCP no están disponibles, diagnosticar el fallo y comunicar los escenarios pendientes. No presentar como superadas comprobaciones que no se han ejecutado.
|
||||
|
||||
@ -133,6 +133,80 @@ Localiza la raíz del clon, mantiene el nombre `playwright` y conserva el resto
|
||||
|
||||
Este mecanismo alternativo inicia el CLI; no registra automáticamente herramientas en la extensión o Desktop. Si sus políticas administradas impiden ejecutar Docker, resolver los permisos con el administrador: el lanzador no evita esas restricciones. El comprobador Python permite verificar stdio y la captura independientemente de la carga de herramientas del cliente, sin afirmar que con ello Desktop o la extensión estén validados.
|
||||
|
||||
## Auditorías visuales autenticadas
|
||||
|
||||
### Autenticación existente y decisión
|
||||
|
||||
No hay usuarios de desarrollo predeterminados. La instalación real se configura mediante `/api/auth/bootstrap`, que crea un administrador inicial protegido; `/api/users` permite a ese administrador crear usuarios ordinarios. No existe un catálogo general de roles: la gestión de usuarios requiere ser el administrador inicial, mientras el usuario ordinario puede acceder al espacio de soporte.
|
||||
|
||||
`/api/auth/login` comprueba la contraseña mediante scrypt y renueva la sesión. MariaDB guarda las sesiones; la cookie `metis.sid` es HttpOnly, SameSite=Strict y dura ocho horas. En producción también es Secure. No se guarda un token de acceso en localStorage. `/api/auth/session` proporciona el token CSRF y las operaciones de escritura exigen ese token y el origen configurado. `/api/auth/logout` destruye la sesión y elimina la cookie. El servidor comprueba usuario activo y versión de autenticación; la protección de datos no depende solo de las redirecciones del cliente. Véase [usuarios y acceso](usuarios-y-acceso.md).
|
||||
|
||||
El proyecto dispone de `metis_desk_test` y fixtures de integración, pero algunas pruebas limpian tablas o fabrican sesiones para probar contratos concretos. **No ejecutar esas pruebas para preparar una auditoría visual**: tampoco se utiliza la base de pruebas existente.
|
||||
|
||||
`scripts/visual-audit.py` prepara una instalación desechable mediante los endpoints públicos reales:
|
||||
|
||||
1. Crea un contenedor MariaDB independiente, con un volumen anónimo, nombre aleatorio, puerto libre publicado solo en `127.0.0.1`, `metis_desk_test` y contraseñas aleatorias.
|
||||
2. Arranca la API existente con esa conexión, un secreto de sesión aleatorio y `NODE_ENV=test`. No carga `.env` ni acepta una conexión de base de datos suministrada por el usuario.
|
||||
3. Ejecuta el bootstrap normal y crea un usuario ordinario, una compañía sintética y un portátil a través de HTTP con sesión y CSRF válidos. El administrador solo sirve para preparar esta instalación temporal: no se conserva en ninguna base existente.
|
||||
4. Cierra la sesión de preparación. Arranca Vite en otro puerto libre, con el proxy y `APP_ORIGIN` alineados automáticamente.
|
||||
5. Playwright MCP abre el formulario real, rellena las credenciales generadas y pulsa Entrar. Comprueba la identidad ordinaria y el rechazo 403 de `/api/users`.
|
||||
6. Abre listado, ficha y edición del activo; captura las pantallas y cancela el diálogo sin guardar. Exporta la sesión obtenida y verifica su reutilización desde otro contenedor MCP.
|
||||
|
||||
No se añaden endpoints de acceso especial, cookies fabricadas, excepciones de autorización ni cambios en código de negocio. MariaDB utiliza el índice oficial fijado en el script, contrastado con el registro: `mariadb:11.8@sha256:6422478cb8e159f080fb1d8ccf65101e26fe51385787fde7d16c3b165a331f15`. Al actualizarlo, verificar el nuevo índice y repetir ambas modalidades. La imagen MCP sigue procediendo de `.codex/config.toml`.
|
||||
|
||||
### Preparación en otro WSL
|
||||
|
||||
Se necesitan los mismos requisitos de este documento, las dependencias del proyecto y Docker con acceso a Microsoft Container Registry y Docker Hub. Desde la raíz del clon:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
bash scripts/setup-playwright-wsl.sh
|
||||
python3 scripts/visual-audit.py --check
|
||||
```
|
||||
|
||||
Este procedimiento no necesita `pnpm db:up`, una copia de `.env` ni datos reales. `--check` ejecuta el recorrido, comprueba que logout devuelve 200 y que después el acceso protegido devuelve 401, y retira los servicios. Conserva capturas y un informe sin cookies; termina con código distinto de cero si falla. No valida por sí solo la exposición de las herramientas en un cliente Codex concreto.
|
||||
|
||||
### Mantener una sesión disponible para Codex
|
||||
|
||||
En una terminal WSL, desde el clon:
|
||||
|
||||
```bash
|
||||
python3 scripts/visual-audit.py
|
||||
```
|
||||
|
||||
Esperar el mensaje de verificación. El proceso imprime la URL exacta del inventario temporal y el comando para otra terminal, con su ruta real de sesión. Copiar ese comando, que tiene esta forma:
|
||||
|
||||
```bash
|
||||
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
|
||||
bash scripts/codex-playwright-wsl.sh
|
||||
```
|
||||
|
||||
Sustituir `authenticated-ID` por el directorio que imprime el proceso. Las rutas relativas se resuelven contra la raíz del clon. Si se necesita el override de compatibilidad de la CLI antigua, añadir `-c 'service_tier="fast"'` al lanzador. No fijar rutas personales ni versiones duplicadas en la configuración global.
|
||||
|
||||
El lanzador mantiene la entrada `playwright` y añade solamente para ese proceso un montaje de solo lectura del archivo y `--storage-state`. Usa el UID/GID del usuario WSL para leer el archivo privado sin cambiar sus permisos. Comprueba que sea un archivo propio con permisos restringidos dentro de `.artifacts/playwright` y que contenga únicamente cookies de sesión locales de Metis. Sin la variable, conserva el comportamiento habitual sin sesión precargada. No modifica `.codex/config.toml` ni el archivo global.
|
||||
|
||||
Abrir una sesión nueva y comprobar `/mcp`. Navegar a **la URL temporal impresa**, no a la instancia habitual en 5173. Mantener abierto `visual-audit.py` durante el trabajo. El modo interactivo conserva la sesión; no ejecuta la prueba final de logout porque la invalidaría para Codex.
|
||||
|
||||
Cerrar primero la sesión de auditoría de Codex y después pulsar Ctrl+C en el proceso preparador. Se detienen sus grupos de procesos de API/Vite, se retira su contenedor y volumen anónimo y se elimina `session.json`. No se detiene MariaDB del proyecto ni se borran bases existentes. Cada ejecución genera un entorno nuevo; no reutiliza ni resetea datos de ejecuciones anteriores.
|
||||
|
||||
### Credenciales, sesiones y evidencia
|
||||
|
||||
- Las contraseñas del administrador y usuario son aleatorias y permanecen en memoria. El archivo temporal necesario para arrancar MariaDB se crea con modo 0600 y se elimina al arrancar el contenedor. No se imprimen credenciales, cookies, tokens ni resultados de rellenado del formulario.
|
||||
- El directorio de ejecución tiene modo 0700 y `session.json`, modo 0600. La sesión es sensible aunque corresponda a datos sintéticos; no copiarla a mensajes ni guardarla en Git. Este tratamiento sigue el modelo de [estado autenticado de Playwright](https://playwright.dev/docs/auth).
|
||||
- El directorio `.artifacts/playwright/` ya está excluido de Git. No guardar estados de navegador en otras ubicaciones. El archivo contiene el estado de una sesión real, no un mecanismo alternativo de autenticación; su validez depende del servidor y de la sesión persistida.
|
||||
- Se guardan `00-login.png`, `01-authenticated.png`, `02-assets.png`, `03-detail.png`, `04-dialog.png`, `report.json` y los logs locales de API/Vite. Las capturas son del viewport, para conservar una composición legible en una aplicación con scroll interno. El informe contiene rutas, identificadores sintéticos, resultados y hashes de capturas, nunca cookies o contraseñas.
|
||||
- Revisar las imágenes antes de afirmar una inspección visual. El recorrido solo comprueba acceso y navegación básica; no repite la auditoría UI/UX completa.
|
||||
|
||||
### Diagnóstico y límites
|
||||
|
||||
Si falla el arranque, revisar `api.log` y `vite.log` del directorio indicado. Los cuerpos de errores HTTP y los resultados de herramientas de login no se vuelcan, porque pueden repetir información de sesión. El plazo de disponibilidad está acotado y las esperas de interfaz usan los elementos reales de Playwright.
|
||||
|
||||
Tras terminar el preparador, su archivo de sesión deja de existir. Si se cierra sesión desde un cliente, también se invalida para otros contextos que compartan esa cookie; volver a preparar el entorno. No reutilizar estados caducados ni intentar recuperar credenciales desde los logs.
|
||||
|
||||
Una terminación forzada con SIGKILL o un fallo del daemon puede impedir el cleanup. Identificar los contenedores propios con `docker ps -a --filter name=metis-visual-` y sus volúmenes; retirar únicamente el nombre exacto de la ejecución afectada mediante `docker rm -f -v NOMBRE_DEL_CONTENEDOR_DE_AUDITORIA`. No usar limpiezas globales, `compose down -v` ni borrar el volumen de Metis. Las sesiones que queden en directorios de ejecuciones interrumpidas también deben retirarse.
|
||||
|
||||
La base es desechable, pero una auditoría sigue siendo de lectura salvo que la tarea autorice probar guardados. El conjunto inicial solo contiene una compañía y un activo: otras pantallas pueden necesitar fixtures adicionales explícitas. El usuario ordinario no permite auditar administración de usuarios. La sesión preparada se utiliza con el CLI WSL; no se ha comprobado su carga en extensión o Desktop. Tampoco se ha ejecutado esta modalidad en Docker Desktop o ARM. No hay navegador instalado en WSL ni cambios de seguridad de producción.
|
||||
|
||||
## Conectividad y origen
|
||||
|
||||
La URL del navegador y `APP_ORIGIN` deben coincidir exactamente en esquema, host y puerto. `localhost` y `127.0.0.1` son orígenes distintos aunque alcancen el mismo servicio. Si se usa el origen LAN del proyecto, comprobar ese origen y la configuración de la API; no desactivar sus protecciones.
|
||||
@ -202,7 +276,7 @@ Comprobación ejecutada el 21 de octubre de 2026:
|
||||
- Se retiraron los contenedores MCP temporales y se detuvieron la API y Vite arrancados para esta comprobación. Se conservó el contenedor de MariaDB existente y la imagen descargada en la caché Docker.
|
||||
- Se comprobó la sintaxis Python y TOML, el rechazo de URLs con credenciales y la exclusión de artefactos de Git. No se ejecutaron typecheck, lint, tests ni build del negocio porque los cambios se limitan a configuración, documentación y el comprobador independiente.
|
||||
|
||||
La comprobación no cubre sesiones autenticadas, formularios de negocio, responsive ni accesibilidad completa. Docker Desktop y ejecución arm64 siguen pendientes de validación. La existencia de sus opciones o manifiestos no equivale a una prueba en esas plataformas.
|
||||
La comprobación inicial no cubrió sesiones autenticadas ni formularios de negocio; su ampliación figura más abajo. Responsive y accesibilidad completa, Docker Desktop y ejecución arm64 siguen pendientes de validación. La existencia de sus opciones o manifiestos no equivale a una prueba en esas plataformas.
|
||||
|
||||
### Ampliación del procedimiento WSL
|
||||
|
||||
@ -211,3 +285,15 @@ En el mismo entorno se ejecutó `bash scripts/setup-playwright-wsl.sh` dos veces
|
||||
Se contrastó el listado real del CLI 0.114.0 con y sin el lanzador alternativo: ambos muestran una sola entrada `playwright`. Se mantuvo el override puntual de `service_tier`, sin modificar configuración global. También se comprobó con un ejecutable de diagnóstico que el lanzador serializa overrides TOML válidos, funciona desde otro directorio y conserva stdin; no se atribuye esa comprobación a una sesión interactiva completa de Codex.
|
||||
|
||||
Se repitió la navegación real mediante `scripts/check-playwright-mcp.py` contra API de prueba y Vite: devolvió las 25 herramientas y la misma captura PNG de acceso, que se abrió e inspeccionó. Se registró de nuevo el 404 del favicon. La disponibilidad directa de las herramientas en esta sesión de agente, la extensión y Desktop permanece sin validar; el comprobador stdio y la carga de configuración del CLI son evidencias separadas.
|
||||
|
||||
### Verificación de acceso autenticado
|
||||
|
||||
Ejecutada el 21 de octubre de 2026, con Docker Engine local en WSL amd64:
|
||||
|
||||
- `python3 scripts/visual-audit.py --check`: login desde el formulario real, usuario ordinario, inventario, enlace a ficha, diálogo de edición y cancelación. Se generaron las cinco capturas y se comprobó la reutilización de la sesión desde otro contenedor. Se verificaron 401 sin autenticar, 403 en gestión de usuarios y logout 200 seguido de acceso protegido 401.
|
||||
- `python3 scripts/visual-audit.py`: mismo recorrido en modalidad mantenida, con capturas de viewport de 1280×900 abiertas e inspeccionadas. Se verificaron permisos 0700 del directorio y 0600 de la sesión.
|
||||
- El CLI 0.114.0 reconoció una sola entrada `playwright` con el montaje privado de solo lectura. Se tomó la configuración producida realmente por el lanzador y se inició con ella otro MCP: abrió el listado autenticado correctamente. Esto valida configuración y transporte; no afirma haber ejecutado una conversación interactiva nueva en Codex ni haber probado la extensión o Desktop.
|
||||
- `python3 scripts/visual-audit.test.py`: seis pruebas correctas de comportamiento habitual, montaje privado, conservación de stdin, rechazo de permisos abiertos, cookies remotas, rutas externas y sesión inexistente. `bash -n`, sintaxis Python y `git diff --check`: correctos. No se ejecutaron pruebas ni compilaciones de negocio, cuyo código no se modifica.
|
||||
- Al cerrar con SIGINT se detuvieron los servicios temporales, se retiró el contenedor desechable y se borraron los archivos de sesión y credenciales. Se comprobó que el contenedor MariaDB previo seguía disponible y que no quedaban contenedores `metis-visual-*`.
|
||||
|
||||
Las imágenes e informes están bajo el directorio local de ejecución y excluidos de Git. La prueba no guarda ediciones del activo ni repite una auditoría visual completa.
|
||||
|
||||
@ -6,6 +6,7 @@ exec python3 -c "$(cat <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import shutil
|
||||
import sys
|
||||
import tomllib
|
||||
@ -16,6 +17,35 @@ if executable is None:
|
||||
sys.exit('Error: instala Codex CLI en WSL o indica METIS_CODEX_BIN.')
|
||||
with (root / '.codex/config.toml').open('rb') as source:
|
||||
config = tomllib.load(source)['mcp_servers']['playwright']
|
||||
state_path = os.environ.get('METIS_PLAYWRIGHT_STORAGE_STATE')
|
||||
if state_path:
|
||||
state = (root / state_path).resolve()
|
||||
try:
|
||||
metadata = state.stat()
|
||||
except OSError:
|
||||
sys.exit('Error: sesión ausente o inválida; vuelve a iniciar scripts/visual-audit.py.')
|
||||
if (not state.is_relative_to(root / '.artifacts/playwright') or not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) & 0o077):
|
||||
sys.exit('Error: la sesión debe ser un archivo propio y privado dentro de .artifacts/playwright.')
|
||||
try:
|
||||
data = json.loads(state.read_text())
|
||||
except (OSError, ValueError):
|
||||
sys.exit('Error: sesión inválida; vuelve a iniciar scripts/visual-audit.py.')
|
||||
if not isinstance(data, dict) or not isinstance(data.get('cookies'), list):
|
||||
sys.exit('Error: formato de sesión Playwright inválido.')
|
||||
cookies = data['cookies']
|
||||
if not cookies or any(not isinstance(cookie, dict) or cookie.get('domain') not in ('localhost', '127.0.0.1')
|
||||
or cookie.get('name') != 'metis.sid' or cookie.get('httpOnly') is not True
|
||||
for cookie in cookies):
|
||||
sys.exit('Error: solo se admiten sesiones locales de auditoría de Metis.')
|
||||
args = list(config['args'])
|
||||
image_position = next(index for index, arg in enumerate(args) if arg.startswith('mcr.microsoft.com/playwright/mcp:'))
|
||||
args[image_position:image_position] = [
|
||||
'--user=' + str(os.getuid()) + ':' + str(os.getgid()),
|
||||
'--mount=type=bind,source=' + str(state) + ',target=/tmp/metis-audit-state.json,readonly',
|
||||
]
|
||||
args.append('--storage-state=/tmp/metis-audit-state.json')
|
||||
config['args'] = args
|
||||
overrides = []
|
||||
for key in ('command', 'args', 'startup_timeout_sec', 'tool_timeout_sec'):
|
||||
overrides.extend(['-c', 'mcp_servers.playwright.' + key + '=' + json.dumps(config[key])])
|
||||
|
||||
332
scripts/visual-audit.py
Normal file
332
scripts/visual-audit.py
Normal file
@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepara datos desechables y verifica un acceso real mediante Playwright MCP."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ARTIFACTS = ROOT / ".artifacts/playwright"
|
||||
# Índice oficial contrastado con el registro; no utiliza el volumen de compose del proyecto.
|
||||
DATABASE_IMAGE = "mariadb:11.8@sha256:6422478cb8e159f080fb1d8ccf65101e26fe51385787fde7d16c3b165a331f15"
|
||||
|
||||
|
||||
def private_write(path, text):
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(text)
|
||||
|
||||
|
||||
def command(args, **options):
|
||||
return subprocess.run(args, check=True, timeout=120, capture_output=True, text=True, **options)
|
||||
|
||||
|
||||
def available_port():
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
return listener.getsockname()[1]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def child_process(args, environment, log):
|
||||
with log.open("w", encoding="utf-8") as stream:
|
||||
process = subprocess.Popen(args, cwd=ROOT, env=environment, stdout=stream,
|
||||
stderr=stream, start_new_session=True)
|
||||
try:
|
||||
yield process
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
def wait_for(check, process=None):
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if process is not None and process.poll() is not None:
|
||||
raise RuntimeError("Un servidor temporal terminó antes de estar disponible; revisa sus logs locales.")
|
||||
if check():
|
||||
return
|
||||
# Reintento condicionado a disponibilidad, con plazo; no sustituye una espera de UI.
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError("Tiempo agotado esperando un servicio temporal.")
|
||||
|
||||
|
||||
class TestApi:
|
||||
"""Cliente del HTTP público; nunca fabrica cookies, sesiones ni tokens CSRF."""
|
||||
|
||||
def __init__(self, base, origin):
|
||||
self.base = base
|
||||
self.origin = origin
|
||||
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
|
||||
def request(self, path, body=None, expected=200):
|
||||
headers = {}
|
||||
if body is not None:
|
||||
token = self.request("/auth/session")["csrfToken"]
|
||||
headers = {"content-type": "application/json", "origin": self.origin, "x-csrf-token": token}
|
||||
request = urllib.request.Request(self.base + "/api" + path,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers=headers)
|
||||
try:
|
||||
with self.opener.open(request, timeout=20) as response:
|
||||
if response.status != expected:
|
||||
raise RuntimeError("Respuesta HTTP inesperada en " + path)
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == expected:
|
||||
return None
|
||||
# Los cuerpos pueden contener datos de sesión; no se vuelcan en el diagnóstico.
|
||||
raise RuntimeError(f"HTTP {error.code} en {path}") from None
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
self.request("/auth/session")
|
||||
return True
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def prepare_fixture(api):
|
||||
if not api.request("/auth/session")["setupRequired"]:
|
||||
raise RuntimeError("La instancia desechable no está vacía; se rechaza preparar datos.")
|
||||
password = secrets.token_urlsafe(32)
|
||||
api.request("/auth/bootstrap", {"name": "Preparación temporal", "email": "setup@audit.example.test",
|
||||
"password": secrets.token_urlsafe(32)}, expected=201)
|
||||
user = api.request("/users", {"name": "Auditoría visual", "email": "visual@audit.example.test",
|
||||
"password": password}, expected=201)["user"]
|
||||
if user["isInitialAdmin"]:
|
||||
raise RuntimeError("La cuenta de auditoría debe ser ordinaria.")
|
||||
company = api.request("/companies", {"name": "Auditoría visual — Datos sintéticos", "active": True}, expected=201)["company"]
|
||||
company_id = str(uuid.UUID(company["id"]))
|
||||
catalog = api.request("/asset-catalog/" + company_id)["items"]
|
||||
template = next((item for item in catalog if item["values"]["kind"] == "template"
|
||||
and item["values"]["name"] == "Portátil"), None)
|
||||
if template is None:
|
||||
raise RuntimeError("El catálogo de serie no incluye la plantilla Portátil.")
|
||||
asset = api.request("/assets/" + company_id, {"values": {
|
||||
"name": "AUDIT-PORT-01", "templateId": template["id"], "siteId": None,
|
||||
"registeredOn": "2026-01-15", "status": "operational", "notes": "Activo sintético para auditorías visuales.",
|
||||
"characteristics": {},
|
||||
}}, expected=201)["asset"]
|
||||
api.request("/auth/logout", {})
|
||||
return {"company": company_id, "asset": str(uuid.UUID(asset["id"])), "user": user["id"],
|
||||
"email": "visual@audit.example.test", "password": password}
|
||||
|
||||
|
||||
def tool(client, name, arguments):
|
||||
try:
|
||||
return client.tool(name, arguments)
|
||||
except (RuntimeError, OSError) as error:
|
||||
# Las respuestas de rellenado pueden repetir credenciales. No imprimirlas ni guardarlas.
|
||||
if name == "browser_run_code_unsafe":
|
||||
# Solo categorías de error conocidas; nunca contenido, argumentos ni snapshots.
|
||||
category = "timeout" if "Timeout" in str(error) else "ejecución de código"
|
||||
raise RuntimeError("Falló " + name + " (" + category + ").") from None
|
||||
raise RuntimeError("Falló " + name + "; no se registra su contenido para proteger la sesión.") from None
|
||||
|
||||
|
||||
def result_json(result):
|
||||
for part in result.get("content", []):
|
||||
if part.get("type") == "text" and "### Result\n" in part["text"]:
|
||||
return json.JSONDecoder().raw_decode(part["text"].split("### Result\n", 1)[1].lstrip())[0]
|
||||
raise RuntimeError("El MCP no devolvió el resultado JSON esperado.")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def browser(config, state=None):
|
||||
name = "metis-visual-browser-" + uuid.uuid4().hex[:12]
|
||||
args = ["docker", "run", "--name=" + name, *config["args"][1:]]
|
||||
if state is not None:
|
||||
image_position = next(index for index, arg in enumerate(args) if arg.startswith("mcr.microsoft.com/playwright/mcp:"))
|
||||
args[image_position:image_position] = ["--user=" + str(os.getuid()) + ":" + str(os.getgid()),
|
||||
"--mount=type=bind,source=" + str(state) + ",target=/tmp/metis-audit-state.json,readonly"]
|
||||
args.append("--storage-state=/tmp/metis-audit-state.json")
|
||||
# Reutiliza el cliente de diagnóstico existente, sin añadir un SDK ni duplicar JSON-RPC.
|
||||
client_type = runpy.run_path(str(ROOT / "scripts/check-playwright-mcp.py"))["StdioClient"]
|
||||
client = client_type(args, 120)
|
||||
try:
|
||||
client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "metis-authenticated-audit", "version": "1"}})
|
||||
client.send({"method": "notifications/initialized"})
|
||||
yield client
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
finally:
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=15, check=False)
|
||||
|
||||
|
||||
def capture(client, directory, name):
|
||||
result = tool(client, "browser_take_screenshot", {"type": "png", "fullPage": False, "scale": "css"})
|
||||
part = next(item for item in result["content"] if item.get("type") == "image" and item.get("mimeType") == "image/png")
|
||||
data = base64.b64decode(part["data"], validate=True)
|
||||
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise RuntimeError("Captura PNG inválida.")
|
||||
destination = directory / (name + ".png")
|
||||
destination.write_bytes(data)
|
||||
return {"file": destination.name, "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def verify_browser(config, origin, fixture, directory, state, logout):
|
||||
code_tool = "browser_run_code_unsafe"
|
||||
evidence = []
|
||||
assets_path = "/companies/" + fixture["company"] + "/assets"
|
||||
with browser(config) as client:
|
||||
print("Verificando el formulario de acceso real…", flush=True)
|
||||
tool(client, "browser_resize", {"width": 1280, "height": 900})
|
||||
tool(client, "browser_navigate", {"url": origin + "/login"})
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Entrar', exact:true}).waitFor(); }"})
|
||||
evidence.append(capture(client, directory, "00-login"))
|
||||
tool(client, "browser_fill_form", {"fields": [
|
||||
{"name": "Correo electrónico", "type": "textbox", "target": "input[type=email]", "value": fixture["email"]},
|
||||
{"name": "Contraseña", "type": "textbox", "target": "input[type=password]", "value": fixture["password"]},
|
||||
]})
|
||||
tool(client, "browser_click", {"target": "button[type=submit]"})
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.locator('[data-slot=workspace]').waitFor({timeout:30000}); }"})
|
||||
identity = result_json(tool(client, "browser_evaluate", {"function": """async () => {
|
||||
const response = await fetch('/api/auth/session');
|
||||
const {user} = await response.json();
|
||||
return {id:user?.id, admin:user?.isInitialAdmin, usersStatus:(await fetch('/api/users')).status};
|
||||
}"""}))
|
||||
if identity != {"id": fixture["user"], "admin": False, "usersStatus": 403}:
|
||||
raise RuntimeError("La sesión no corresponde al usuario ordinario esperado.")
|
||||
evidence.append(capture(client, directory, "01-authenticated"))
|
||||
print("Usuario ordinario autenticado; verificando listado, ficha y diálogo…", flush=True)
|
||||
tool(client, "browser_navigate", {"url": origin + assets_path})
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
|
||||
evidence.append(capture(client, directory, "02-assets"))
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).click(); await page.getByRole('heading', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
|
||||
evidence.append(capture(client, directory, "03-detail"))
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('button', {name:'Editar activo', exact:true}).click(); await page.getByRole('dialog', {name:'Editar activo', exact:true}).waitFor(); }"})
|
||||
evidence.append(capture(client, directory, "04-dialog"))
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('dialog').getByRole('button', {name:'Cancelar', exact:true}).click(); await page.getByRole('dialog').waitFor({state:'hidden'}); }"})
|
||||
storage = result_json(tool(client, code_tool, {"code": "async (page) => await page.context().storageState()"}))
|
||||
private_write(state, json.dumps(storage))
|
||||
# Otro contenedor comprueba la reutilización por el mismo mecanismo que recibirá Codex.
|
||||
with browser(config, state) as client:
|
||||
tool(client, "browser_navigate", {"url": origin + assets_path})
|
||||
tool(client, code_tool, {"code": "async (page) => { await page.getByRole('link', {name:'AUDIT-PORT-01', exact:true}).waitFor(); }"})
|
||||
if logout:
|
||||
result = result_json(tool(client, "browser_evaluate", {"function": """async () => {
|
||||
const {csrfToken} = await (await fetch('/api/auth/session')).json();
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method:'POST', body:'{}', headers:{'content-type':'application/json', 'x-csrf-token':csrfToken}
|
||||
});
|
||||
return {logout:response.status, protected:(await fetch('/api/companies')).status};
|
||||
}"""}))
|
||||
if result != {"logout": 200, "protected": 401}:
|
||||
raise RuntimeError("El cierre de sesión no revocó el acceso esperado.")
|
||||
return evidence
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="Verifica y retira el entorno; sin esta opción espera hasta Ctrl+C")
|
||||
options = parser.parse_args()
|
||||
for executable in ("docker", "node", "pnpm"):
|
||||
if shutil.which(executable) is None:
|
||||
parser.error("Falta " + executable + "; consulta docs/playwright-mcp.md")
|
||||
if not command(["node", "--version"]).stdout.startswith("v24."):
|
||||
parser.error("Se necesita Node.js 24.x.")
|
||||
with (ROOT / ".codex/config.toml").open("rb") as source:
|
||||
config = tomllib.load(source)["mcp_servers"]["playwright"]
|
||||
images = [arg for arg in config["args"] if re.fullmatch(r"mcr\.microsoft\.com/playwright/mcp:v[\d.]+@sha256:[a-f0-9]{64}", arg)]
|
||||
if config["command"] != "docker" or len(images) != 1 or "--network=host" not in config["args"]:
|
||||
parser.error("Se requiere la configuración Docker fijada con red host.")
|
||||
ARTIFACTS.mkdir(parents=True, exist_ok=True)
|
||||
directory = Path(tempfile.mkdtemp(prefix="authenticated-", dir=ARTIFACTS))
|
||||
container = "metis-visual-db-" + uuid.uuid4().hex[:12]
|
||||
container_created = False
|
||||
database_password = secrets.token_urlsafe(32)
|
||||
api_port, web_port = available_port(), available_port()
|
||||
while api_port == web_port:
|
||||
web_port = available_port()
|
||||
origin = "http://localhost:" + str(web_port)
|
||||
api = TestApi("http://127.0.0.1:" + str(api_port), origin)
|
||||
state = directory / "session.json"
|
||||
env_file = directory / "database.env"
|
||||
try:
|
||||
private_write(env_file, "MARIADB_DATABASE=metis_desk_test\nMARIADB_USER=visual_audit\nMARIADB_PASSWORD=" + database_password + "\nMARIADB_ROOT_PASSWORD=" + secrets.token_urlsafe(32) + "\n")
|
||||
print("Preparando MariaDB desechable y servicios en puertos libres…", flush=True)
|
||||
command(["docker", "run", "--detach", "--rm", "--name=" + container,
|
||||
"--label=metis.visual-audit=true", "--publish=127.0.0.1::3306",
|
||||
"--env-file=" + str(env_file), DATABASE_IMAGE])
|
||||
container_created = True
|
||||
env_file.unlink()
|
||||
wait_for(lambda: subprocess.run(["docker", "exec", container, "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||
capture_output=True, timeout=15, check=False).returncode == 0)
|
||||
bindings = json.loads(command(["docker", "inspect", "--format={{json .NetworkSettings.Ports}}", container]).stdout)
|
||||
database_port = bindings["3306/tcp"][0]["HostPort"]
|
||||
# No carga .env: las únicas credenciales apuntan al contenedor recién creado.
|
||||
environment = {**os.environ, "DB_HOST": "127.0.0.1", "DB_PORT": database_port,
|
||||
"DB_NAME": "metis_desk_test", "DB_USER": "visual_audit", "DB_PASSWORD": database_password,
|
||||
"SESSION_SECRET": secrets.token_urlsafe(48), "NODE_ENV": "test", "APP_ORIGIN": origin,
|
||||
"PORT": str(api_port), "API_PROXY_TARGET": api.base}
|
||||
with child_process(["node", "--import", "tsx", "apps/server/src/index.ts"], environment, directory / "api.log") as backend:
|
||||
wait_for(api.ready, backend)
|
||||
fixture = prepare_fixture(api)
|
||||
TestApi(api.base, origin).request("/companies", expected=401)
|
||||
with child_process(["pnpm", "--config.verify-deps-before-run=false", "--filter", "@metis/web", "exec", "vite",
|
||||
"--host", "127.0.0.1", "--port", str(web_port), "--strictPort"], environment, directory / "vite.log") as frontend:
|
||||
web = TestApi(origin, origin)
|
||||
wait_for(web.ready, frontend)
|
||||
evidence = verify_browser(config, origin, fixture, directory, state, options.check)
|
||||
report = {"origin": origin, "companyId": fixture["company"], "assetId": fixture["asset"],
|
||||
"ordinaryUser": True, "unauthenticatedStatus": 401, "userManagementStatus": 403,
|
||||
"reusedSession": True, "logoutChecked": options.check,
|
||||
"databaseImage": DATABASE_IMAGE, "mcpImage": images[0], "screenshots": evidence}
|
||||
(directory / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print("Verificado: login real, usuario ordinario, listado, ficha, diálogo cancelado y sesión en otro contenedor.", flush=True)
|
||||
print("Evidencia local: " + str(directory.relative_to(ROOT)), flush=True)
|
||||
if not options.check:
|
||||
print("Mantén este proceso abierto. En otra terminal WSL:", flush=True)
|
||||
print("METIS_PLAYWRIGHT_STORAGE_STATE=" + str(state.relative_to(ROOT)) + " bash scripts/codex-playwright-wsl.sh", flush=True)
|
||||
print("URL de auditoría: " + origin + "/companies/" + fixture["company"] + "/assets", flush=True)
|
||||
print("Ctrl+C retira exclusivamente este entorno y su sesión.", flush=True)
|
||||
signal.pause()
|
||||
finally:
|
||||
env_file.unlink(missing_ok=True)
|
||||
state.unlink(missing_ok=True)
|
||||
if container_created:
|
||||
removed = subprocess.run(["docker", "rm", "-f", "-v", container], capture_output=True, timeout=30, check=False)
|
||||
if removed.returncode != 0 and "No such container" not in removed.stderr.decode(errors="replace"):
|
||||
raise RuntimeError("No se pudo confirmar la retirada del contenedor temporal; consulta la limpieza manual documentada.")
|
||||
print("Entorno temporal retirado; las capturas se conservan sin sesión ni credenciales.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(130))
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("Auditoría finalizada.")
|
||||
except (OSError, RuntimeError, ValueError, KeyError, StopIteration, subprocess.SubprocessError) as error:
|
||||
# No volcar excepciones de HTTP/MCP: podrían incluir contraseñas o cookies.
|
||||
print("La comprobación falló. Revisa los requisitos Docker/WSL y los logs de servicios locales; no se ha validado la integración.", file=sys.stderr)
|
||||
if isinstance(error, RuntimeError):
|
||||
print(str(error), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
87
scripts/visual-audit.test.py
Normal file
87
scripts/visual-audit.test.py
Normal file
@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regresión del aislamiento de las sesiones del lanzador, sin Docker ni credenciales."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class SessionLauncherTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
artifacts = ROOT / ".artifacts/playwright"
|
||||
artifacts.mkdir(parents=True, exist_ok=True)
|
||||
self.directory = tempfile.TemporaryDirectory(prefix="session-guard-", dir=artifacts)
|
||||
self.root = Path(self.directory.name)
|
||||
self.executable = self.root / "codex-probe"
|
||||
self.executable.write_text("#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps({'args':sys.argv[1:],'stdin':sys.stdin.read()}))\n")
|
||||
self.executable.chmod(0o700)
|
||||
|
||||
def tearDown(self):
|
||||
self.directory.cleanup()
|
||||
|
||||
def state(self, domain="localhost", mode=0o600):
|
||||
path = self.root / "session.json"
|
||||
path.write_text(json.dumps({"cookies": [{"name": "metis.sid", "domain": domain,
|
||||
"httpOnly": True, "value": "placeholder-not-a-session"}]}))
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
def launch(self, state=None):
|
||||
environment = {**os.environ, "METIS_CODEX_BIN": str(self.executable)}
|
||||
environment.pop("METIS_PLAYWRIGHT_STORAGE_STATE", None)
|
||||
if state is not None:
|
||||
environment["METIS_PLAYWRIGHT_STORAGE_STATE"] = str(state)
|
||||
return subprocess.run(["bash", str(ROOT / "scripts/codex-playwright-wsl.sh"), "mcp", "list", "--json"],
|
||||
input="stdin-check", text=True, capture_output=True, env=environment,
|
||||
cwd=self.root, timeout=15, check=False)
|
||||
|
||||
def args(self, result):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["stdin"], "stdin-check")
|
||||
argument = next(item for item in payload["args"] if item.startswith("mcp_servers.playwright.args="))
|
||||
return tomllib.loads(argument)["mcp_servers"]["playwright"]["args"]
|
||||
|
||||
def test_default_has_no_session(self):
|
||||
self.assertFalse(any("storage-state" in item or "type=bind" in item for item in self.args(self.launch())))
|
||||
|
||||
def test_private_session_is_readonly_and_not_in_arguments(self):
|
||||
state = self.state()
|
||||
args = self.args(self.launch(state.relative_to(ROOT)))
|
||||
self.assertTrue(any("readonly" in item and str(state) in item for item in args))
|
||||
self.assertEqual(args.count("--storage-state=/tmp/metis-audit-state.json"), 1)
|
||||
self.assertNotIn("placeholder-not-a-session", " ".join(args))
|
||||
|
||||
def test_public_permissions_are_rejected(self):
|
||||
result = self.launch(self.state(mode=0o644))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("privado", result.stderr)
|
||||
|
||||
def test_remote_cookie_is_rejected(self):
|
||||
result = self.launch(self.state(domain="production.example.test"))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("locales", result.stderr)
|
||||
|
||||
def test_external_file_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "session.json"
|
||||
path.write_text("{}")
|
||||
path.chmod(0o600)
|
||||
result = self.launch(path)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("privado", result.stderr)
|
||||
|
||||
def test_missing_session_is_rejected(self):
|
||||
result = self.launch(self.root / "missing.json")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("ausente", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue
Block a user