Spaces:
Sleeping
No tragar errores de programación al leer la sesión
Browse filesleer_sesion capturaba (BadSignature, Exception). El `Exception` hacía redundante
el `BadSignature` y, sobre todo, convertía cualquier error interno en «no hay
sesión»: un bug en la capa de sesión salía como un 401 silencioso en vez de
propagarse, que es justo lo que no se quiere en el camino de autenticación.
Se captura BadData, el padre común de:
- BadSignature firma inválida o falsificada
- SignatureExpired sesión caducada (subclase de BadSignature)
- BadPayload token corrupto
Las tres son «cookie no válida» → sin sesión. Un TypeError o un fallo de
configuración ahora se propagan.
Nota: (BadSignature, SignatureExpired) —la corrección que se propuso primero—
habría repetido el mismo error en pequeño, porque SignatureExpired ya es
subclase de BadSignature.
Se añaden tests de sesión (no había ninguno), incluido el caso negativo que
motiva el cambio: un error interno debe propagarse, no devolver None.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
| 9 |
|
| 10 |
import secrets
|
| 11 |
|
| 12 |
-
from itsdangerous import
|
| 13 |
|
| 14 |
from ..config import obtener_config
|
| 15 |
|
|
@@ -34,7 +34,11 @@ def leer_sesion(token: str | None) -> dict | None:
|
|
| 34 |
cfg = obtener_config()
|
| 35 |
try:
|
| 36 |
return _serializer().loads(token, max_age=cfg.session_max_age_s)
|
| 37 |
-
except
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return None
|
| 39 |
|
| 40 |
|
|
|
|
| 9 |
|
| 10 |
import secrets
|
| 11 |
|
| 12 |
+
from itsdangerous import BadData, URLSafeTimedSerializer
|
| 13 |
|
| 14 |
from ..config import obtener_config
|
| 15 |
|
|
|
|
| 34 |
cfg = obtener_config()
|
| 35 |
try:
|
| 36 |
return _serializer().loads(token, max_age=cfg.session_max_age_s)
|
| 37 |
+
except BadData:
|
| 38 |
+
# BadData es el padre de BadSignature (firma inválida o falsificada), de
|
| 39 |
+
# SignatureExpired (sesión caducada) y de BadPayload (token corrupto): las tres son
|
| 40 |
+
# "cookie no válida" → sin sesión. NO se captura Exception: un TypeError o un fallo de
|
| 41 |
+
# configuración deben propagarse, no disfrazarse de 401 y ocultar el bug real.
|
| 42 |
return None
|
| 43 |
|
| 44 |
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sesiones firmadas por cookie.
|
| 2 |
+
|
| 3 |
+
El caso importante aquí es el NEGATIVO: `leer_sesion` capturaba `(BadSignature, Exception)`, de
|
| 4 |
+
modo que cualquier error de programación se convertía en «no hay sesión» y salía como un 401
|
| 5 |
+
silencioso, ocultando el bug real. Estos tests fijan qué se traga y qué debe propagarse.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from app.security import session as ses
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_ida_y_vuelta():
|
| 16 |
+
token = ses.firmar_sesion({"email": "vet@example.com"})
|
| 17 |
+
assert ses.leer_sesion(token) == {"email": "vet@example.com"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.mark.parametrize(
|
| 21 |
+
"token",
|
| 22 |
+
[
|
| 23 |
+
None,
|
| 24 |
+
"",
|
| 25 |
+
"no-es-un-token",
|
| 26 |
+
"a.b.c",
|
| 27 |
+
"eyJlbWFpbCI6ICJhdGFjYW50ZUBleGFtcGxlLmNvbSJ9.falsificado.firma",
|
| 28 |
+
],
|
| 29 |
+
)
|
| 30 |
+
def test_tokens_invalidos_devuelven_none(token):
|
| 31 |
+
"""Firma inválida, token corrupto o ausente → sin sesión, sin excepción."""
|
| 32 |
+
assert ses.leer_sesion(token) is None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_firma_con_otro_secreto_no_valida(monkeypatch):
|
| 36 |
+
"""Una cookie firmada con otro secreto (p. ej. el fallback de dev) no debe abrir sesión:
|
| 37 |
+
es exactamente el escenario de suplantación que evita MORPHOS_SESSION_SECRET."""
|
| 38 |
+
token = ses.firmar_sesion({"email": "vet@example.com"})
|
| 39 |
+
|
| 40 |
+
ses.obtener_config.cache_clear()
|
| 41 |
+
monkeypatch.setenv("MORPHOS_SESSION_SECRET", "otro-secreto-completamente-distinto-1234567890")
|
| 42 |
+
try:
|
| 43 |
+
assert ses.leer_sesion(token) is None
|
| 44 |
+
finally:
|
| 45 |
+
ses.obtener_config.cache_clear()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_sesion_caducada_devuelve_none(monkeypatch):
|
| 49 |
+
token = ses.firmar_sesion({"email": "vet@example.com"})
|
| 50 |
+
|
| 51 |
+
ses.obtener_config.cache_clear()
|
| 52 |
+
# -1 y no 0: itsdangerous compara `edad > max_age`, así que un token recién creado (edad 0)
|
| 53 |
+
# con max_age=0 todavía es válido. Con -1 se dispara SignatureExpired, subclase de BadData,
|
| 54 |
+
# que es la rama que interesa comprobar.
|
| 55 |
+
monkeypatch.setenv("MORPHOS_SESSION_MAX_AGE_S", "-1")
|
| 56 |
+
try:
|
| 57 |
+
assert ses.leer_sesion(token) is None
|
| 58 |
+
finally:
|
| 59 |
+
ses.obtener_config.cache_clear()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_los_errores_de_programacion_se_propagan(monkeypatch):
|
| 63 |
+
"""El fallo que motiva estos tests: un error interno NO puede disfrazarse de «sin sesión».
|
| 64 |
+
Si `loads` revienta por un bug, debe propagarse para que se vea, no devolver None."""
|
| 65 |
+
|
| 66 |
+
class SerializadorRoto:
|
| 67 |
+
def loads(self, *_args, **_kwargs):
|
| 68 |
+
raise RuntimeError("fallo interno, no es una cookie inválida")
|
| 69 |
+
|
| 70 |
+
monkeypatch.setattr(ses, "_serializer", lambda: SerializadorRoto())
|
| 71 |
+
with pytest.raises(RuntimeError):
|
| 72 |
+
ses.leer_sesion("cualquier-token")
|