feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""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=<jwt>. 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()
|
||||
Reference in New Issue
Block a user