diff --git a/AGENTS.md b/AGENTS.md index d9c9c82..0150d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/playwright-mcp.md b/docs/playwright-mcp.md index 0947794..cd1a479 100644 --- a/docs/playwright-mcp.md +++ b/docs/playwright-mcp.md @@ -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. diff --git a/scripts/codex-playwright-wsl.sh b/scripts/codex-playwright-wsl.sh index 9382b52..1838028 100755 --- a/scripts/codex-playwright-wsl.sh +++ b/scripts/codex-playwright-wsl.sh @@ -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])]) diff --git a/scripts/visual-audit.py b/scripts/visual-audit.py new file mode 100644 index 0000000..286788b --- /dev/null +++ b/scripts/visual-audit.py @@ -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) diff --git a/scripts/visual-audit.test.py b/scripts/visual-audit.test.py new file mode 100644 index 0000000..3f53da6 --- /dev/null +++ b/scripts/visual-audit.test.py @@ -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()