#!/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)