"""Login headless contra un Hoppscotch self-hosted via magic link. Reproduce el flujo de magic link de Hoppscotch sin navegador, leyendo el correo de verificacion desde una instancia Mailpit de pruebas: 1. POST /v1/auth/signin -> deviceIdentifier 2. GET mailpit messages -> ultimo correo "Sign in" para ese email 3. GET mailpit message/{id} -> extrae el token (?token=...) del cuerpo 4. POST /v1/auth/verify -> Set-Cookie access_token + refresh_token Devuelve los JWT de sesion (access_token / refresh_token). El access_token es el que las mutations GraphQL protegidas por GqlAuthGuard esperan en la cookie `access_token` (no en el header Authorization). """ import re import requests # El correo de Hoppscotch incluye un enlace con ?token=. El token es un # JWT (3 segmentos base64url separados por puntos), asi que aceptamos letras, # digitos, guion, guion bajo y punto. _TOKEN_RE = re.compile(r"token=([A-Za-z0-9_\-.]+)") def hoppscotch_login( email: str, *, backend_url: str = "http://localhost:3170", mailpit_url: str = "http://localhost:8025", timeout_s: float = 15.0, ) -> dict: """Obtiene un JWT de sesion de Hoppscotch via magic link (headless). Args: email: correo del usuario que inicia sesion. Debe poder recibir el correo de verificacion en la instancia Mailpit indicada. backend_url: base del backend Hoppscotch (sin barra final). El endpoint REST de auth cuelga de ``{backend_url}/v1/auth/...``. mailpit_url: base de la API de Mailpit donde aterriza el correo de verificacion (sin barra final). timeout_s: timeout por request HTTP en segundos. Returns: Dict. En exito: ``{"status": "ok", "access_token": str, "refresh_token": str, "email": str}``. En error (signin no 201, no llega correo, token no encontrado, verify no 200, o fallo de transporte): ``{"status": "error", "error": str}``. """ session = requests.Session() try: # 1) Signin: pide el magic link. Respuesta 201 con deviceIdentifier. signin = session.post( f"{backend_url}/v1/auth/signin", json={"email": email}, timeout=timeout_s, ) if signin.status_code != 201: return { "status": "error", "error": ( f"signin returned {signin.status_code} " f"(expected 201): {signin.text[:200]}" ), } try: device_identifier = signin.json().get("deviceIdentifier") except ValueError: device_identifier = None if not device_identifier: return { "status": "error", "error": "signin response missing deviceIdentifier", } # 2) Localiza el correo de verificacion mas reciente para este email. messages = session.get( f"{mailpit_url}/api/v1/messages", params={"limit": 5}, timeout=timeout_s, ) if messages.status_code != 200: return { "status": "error", "error": ( f"mailpit messages returned {messages.status_code} " "(expected 200)" ), } try: inbox = messages.json().get("messages") or [] except ValueError: return { "status": "error", "error": "mailpit messages response is not valid JSON", } message_id = None for msg in inbox: recipients = msg.get("To") or [] to_match = any( (addr.get("Address") or "").lower() == email.lower() for addr in recipients ) subject = msg.get("Subject") or "" if to_match and "Sign in" in subject: message_id = msg.get("ID") break if not message_id: return { "status": "error", "error": f"no 'Sign in' email found for {email} in mailpit", } # 3) Descarga el cuerpo del correo y extrae el token. message = session.get( f"{mailpit_url}/api/v1/message/{message_id}", timeout=timeout_s, ) if message.status_code != 200: return { "status": "error", "error": ( f"mailpit message returned {message.status_code} " "(expected 200)" ), } try: body = message.json() except ValueError: return { "status": "error", "error": "mailpit message response is not valid JSON", } haystack = f"{body.get('Text') or ''}\n{body.get('HTML') or ''}" token_match = _TOKEN_RE.search(haystack) if not token_match: return { "status": "error", "error": "magic-link token not found in verification email", } token = token_match.group(1) # 4) Verify: canjea el token + deviceIdentifier por las cookies de # sesion (access_token / refresh_token). verify = session.post( f"{backend_url}/v1/auth/verify", json={"token": token, "deviceIdentifier": device_identifier}, timeout=timeout_s, ) if verify.status_code != 200: return { "status": "error", "error": ( f"verify returned {verify.status_code} " f"(expected 200): {verify.text[:200]}" ), } access_token = session.cookies.get("access_token") refresh_token = session.cookies.get("refresh_token") if not access_token: return { "status": "error", "error": "verify succeeded but no access_token cookie was set", } return { "status": "ok", "access_token": access_token, "refresh_token": refresh_token, "email": email, } except requests.RequestException as exc: return {"status": "error", "error": f"transport error: {exc}"} finally: session.close()