Spaces:
Sleeping
Cerrar el alta de cuentas y apagar la cola de muestras por defecto
Browse filesContención inmediata de ARCHITECTURE_REVIEW §2.2 y §2.1. Ninguna de las dos es
el arreglo estructural —ese es el scoping por tenant— pero ambas reducen hoy la
superficie sin tocar el modelo de datos.
ALTA CERRADA (§2.2). Cualquiera podía POSTear /api/auth/registro y alcanzar
/api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta Claude,
dinero real: `limite_interpret_usuario` era un techo sobre una identidad que
costaba una petición HTTP acuñar. Ahora `registro_abierto=False` +
`registro_allowlist`.
Dos detalles que no son cosméticos:
- La comprobación de allowlist va ANTES que la de existencia. Al revés, un email
no aprobado distinguiría 409 (existe) de 403 (no existe) y el alta sería un
oráculo de la base de usuarios. Fuera de la lista: 403 siempre.
- Es una lista de emails y no un booleano porque `instance/` es efímero (§3.4):
las cuentas desaparecen en cada reinicio, así que un alta cerrada sin lista
dejaría la instancia sin acceso posible. Se avisa al arrancar si la lista está
vacía.
COLA DE MUESTRAS APAGADA (§2.1). `GET /api/lab/pendientes` enumeraba las muestras
de TODAS las clínicas para cualquier sesión, y cada `muestra_id` abre en
/lab/resultados el panel completo más las pistas de paciente (nombre, raza,
sexo). Ahora 404 salvo `lab_pendientes_habilitado=true`; 404 y no 403 para que
apagada sea indistinguible de inexistente. El frontend retira el botón en vez de
dejarlo fallando.
Esto NO cierra §2.1 y el comentario lo dice: el `muestra_id` lo pone el
analizador y suele ser correlativo, así que /lab/resultados sigue siendo
enumerable a fuerza bruta. Lo que desaparece es el volcado en una petición.
PRUEBAS. La suite sigue describiendo el defecto (cerrado); las que sólo necesitan
sesión piden el fixture `alta_abierta`. `_limitador_limpio` (autouse) vacía el
contador de rate limiting entre pruebas: es de proceso y el TestClient sale
siempre de la misma IP, así que al añadir altas empezaron a saltar 429 según el
ORDEN de ejecución.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +23 -0
- backend/.env.example +19 -0
- backend/app/config.py +59 -1
- backend/app/routers/auth.py +8 -0
- backend/app/routers/lab.py +10 -1
- backend/tests/conftest.py +32 -0
- backend/tests/test_api.py +3 -3
- backend/tests/test_lab_ingesta.py +11 -2
- backend/tests/test_registro_cerrado.py +79 -0
- frontend/src/lab-import.ts +7 -0
|
@@ -108,6 +108,29 @@ cuesta el doble, exige retención de datos de 30 días (incompatible con el posi
|
|
| 108 |
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
|
| 109 |
`stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
### Pattern Detection Logic (`analisis.ts`)
|
| 112 |
|
| 113 |
Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
|
|
|
|
| 108 |
privacidad) y sus clasificadores pueden rechazar trabajo clínico legítimo con
|
| 109 |
`stop_reason="refusal"` — ver el comentario en `backend/app/config.py`.
|
| 110 |
|
| 111 |
+
### Admisión de cuentas y superficie de laboratorio
|
| 112 |
+
|
| 113 |
+
Dos defectos que se cerraron y **no se vuelven a abrir sin sustituirlos por algo mejor**:
|
| 114 |
+
|
| 115 |
+
- **El alta está CERRADA** (`registro_abierto=False` + `registro_allowlist`). Una cuenta llega
|
| 116 |
+
a `/api/interpret`, que gasta cuota de ZeroGPU compartida y dinero real por la ruta Claude:
|
| 117 |
+
con el alta abierta, `limite_interpret_usuario` protegía una identidad que costaba una
|
| 118 |
+
petición HTTP acuñar. La comprobación de allowlist va **antes** que la de existencia, si no
|
| 119 |
+
el alta se convierte en un oráculo de qué cuentas hay (403 siempre, nunca 409, fuera de la
|
| 120 |
+
lista). Es una **lista de emails y no un booleano** porque `instance/` es efímero: sin ella,
|
| 121 |
+
el primer reinicio deja la instancia sin cuentas y sin forma de crear ninguna.
|
| 122 |
+
- **`GET /api/lab/pendientes` está apagado** (`lab_pendientes_habilitado=False` → 404). Enumera
|
| 123 |
+
las muestras de todas las clínicas y cada `muestra_id` abre el panel completo más las pistas
|
| 124 |
+
de paciente. **Apagarlo no cierra el agujero y no hay que documentarlo como si lo hiciera**:
|
| 125 |
+
el `muestra_id` lo pone el analizador y suele ser correlativo, así que `/api/lab/resultados`
|
| 126 |
+
sigue siendo enumerable. Lo que elimina es el volcado en una petición. El cierre real es atar
|
| 127 |
+
cada resultado a un tenant y filtrar por sesión (ARCHITECTURE_REVIEW §2.1).
|
| 128 |
+
|
| 129 |
+
Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
|
| 130 |
+
`alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
|
| 131 |
+
entre pruebas: es de proceso y el TestClient sale siempre de la misma IP, así que sin él los
|
| 132 |
+
429 aparecían según el orden de ejecución.
|
| 133 |
+
|
| 134 |
### Pattern Detection Logic (`analisis.ts`)
|
| 135 |
|
| 136 |
Severity thresholds are based on deviation from the reference range. Reference ranges are dynamically adjusted for:
|
|
@@ -11,6 +11,16 @@ MORPHOS_ENTORNO=dev # dev | prod
|
|
| 11 |
MORPHOS_SESSION_SECRET=
|
| 12 |
MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
# --- Base de datos de usuarios (SQLite fuera del webroot por defecto) ---
|
| 15 |
# MORPHOS_DB_PATH=/ruta/fuera/webroot/morphos.db
|
| 16 |
|
|
@@ -66,6 +76,15 @@ MORPHOS_RAG_MAX_POR_LIBRO=2
|
|
| 66 |
# umbral a ojo puede vaciar la recuperación). Calibrar con evals/run_retrieval_eval.py.
|
| 67 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# --- Rate limiting ---
|
| 70 |
MORPHOS_LIMITE_INTERPRET=10/minute
|
| 71 |
MORPHOS_LIMITE_LOGIN=5/minute
|
|
|
|
| 11 |
MORPHOS_SESSION_SECRET=
|
| 12 |
MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
|
| 13 |
|
| 14 |
+
# --- Alta de cuentas (CERRADA por defecto) ---
|
| 15 |
+
# Una cuenta alcanza /api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta
|
| 16 |
+
# Claude, dinero real: el alta abierta era una autorización de gasto para cualquiera.
|
| 17 |
+
#
|
| 18 |
+
# IMPORTANTE en un despliegue: si dejas las dos vacías, NADIE puede darse de alta. Con
|
| 19 |
+
# `instance/` efímero (HF Spaces) las cuentas desaparecen en cada reinicio, así que sin
|
| 20 |
+
# allowlist la instancia se queda sin acceso posible. Pon aquí los emails aprobados.
|
| 21 |
+
# MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@clinica.com
|
| 22 |
+
MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
|
| 23 |
+
|
| 24 |
# --- Base de datos de usuarios (SQLite fuera del webroot por defecto) ---
|
| 25 |
# MORPHOS_DB_PATH=/ruta/fuera/webroot/morphos.db
|
| 26 |
|
|
|
|
| 76 |
# umbral a ojo puede vaciar la recuperación). Calibrar con evals/run_retrieval_eval.py.
|
| 77 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 78 |
|
| 79 |
+
# --- Integración de analizadores de laboratorio ---
|
| 80 |
+
# MORPHOS_LAB_API_KEYS=clave-del-puente-1,clave-del-puente-2
|
| 81 |
+
# Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
|
| 82 |
+
# UI. DESACTIVADA por defecto: el almacén no está segmentado por clínica, así que enumera las
|
| 83 |
+
# muestras de TODAS y cada ID abre el panel completo de analitos. Enciéndela sólo donde las
|
| 84 |
+
# sesiones son de una única clínica. Apagarla NO cierra el agujero (el muestra_id lo pone el
|
| 85 |
+
# analizador y suele ser correlativo): el cierre real es filtrar por tenant.
|
| 86 |
+
MORPHOS_LAB_PENDIENTES_HABILITADO=false
|
| 87 |
+
|
| 88 |
# --- Rate limiting ---
|
| 89 |
MORPHOS_LIMITE_INTERPRET=10/minute
|
| 90 |
MORPHOS_LIMITE_LOGIN=5/minute
|
|
@@ -7,6 +7,7 @@ lo necesario para una función concreta.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import os
|
| 11 |
from functools import lru_cache
|
| 12 |
from pathlib import Path
|
|
@@ -15,6 +16,8 @@ from typing import Annotated
|
|
| 15 |
from pydantic import AliasChoices, Field, field_validator
|
| 16 |
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 17 |
|
|
|
|
|
|
|
| 18 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 19 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 20 |
|
|
@@ -47,6 +50,21 @@ class Configuracion(BaseSettings):
|
|
| 47 |
cookie_secure: bool = Field(default=False) # True en prod (HTTPS)
|
| 48 |
session_max_age_s: int = Field(default=60 * 60 * 8)
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# --- Base de datos (usuarios). Ruta fuera del webroot. ---
|
| 51 |
db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
|
| 52 |
mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
|
|
@@ -232,8 +250,23 @@ class Configuracion(BaseSettings):
|
|
| 232 |
lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 233 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 234 |
lab_persistir: bool = Field(default=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
@field_validator("lab_api_keys", "modelos_locales", mode="before")
|
| 237 |
@classmethod
|
| 238 |
def _dividir_lista(cls, v):
|
| 239 |
"""Acepta lista JSON o cadena separada por comas.
|
|
@@ -270,6 +303,30 @@ class Configuracion(BaseSettings):
|
|
| 270 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 271 |
return permitidos
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
def validar_prod(self) -> None:
|
| 274 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 275 |
if self.entorno != "prod":
|
|
@@ -289,4 +346,5 @@ class Configuracion(BaseSettings):
|
|
| 289 |
def obtener_config() -> Configuracion:
|
| 290 |
cfg = Configuracion()
|
| 291 |
cfg.validar_prod()
|
|
|
|
| 292 |
return cfg
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import logging
|
| 11 |
import os
|
| 12 |
from functools import lru_cache
|
| 13 |
from pathlib import Path
|
|
|
|
| 16 |
from pydantic import AliasChoices, Field, field_validator
|
| 17 |
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 18 |
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
# Raíz del repo (…/morphos). La BD y el índice RAG viven FUERA del directorio servido.
|
| 22 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 23 |
|
|
|
|
| 50 |
cookie_secure: bool = Field(default=False) # True en prod (HTTPS)
|
| 51 |
session_max_age_s: int = Field(default=60 * 60 * 8)
|
| 52 |
|
| 53 |
+
# --- Alta de cuentas ---
|
| 54 |
+
# El alta era ABIERTA: cualquiera podía POSTear /api/auth/registro y alcanzar
|
| 55 |
+
# /api/interpret, que gasta cuota de ZeroGPU compartida y, por la ruta Claude, dinero real.
|
| 56 |
+
# El techo por usuario (`limite_interpret_usuario`) protege una identidad que costaba una
|
| 57 |
+
# petición HTTP acuñar, así que no era un techo.
|
| 58 |
+
#
|
| 59 |
+
# Por defecto CERRADA con lista blanca de emails. La lista (y no un simple booleano) es
|
| 60 |
+
# deliberada: `instance/` es efímero en Spaces, así que las cuentas desaparecen en cada
|
| 61 |
+
# reinicio. Con `registro_abierto=False` y sin lista, tras un reinicio no habría forma de
|
| 62 |
+
# crear ninguna cuenta y la app quedaría inservible; con lista, los aprobados se vuelven a
|
| 63 |
+
# dar de alta solos. Cuando los usuarios vivan en almacenamiento persistente, la lista pasa
|
| 64 |
+
# a ser sólo el control de admisión.
|
| 65 |
+
registro_abierto: bool = Field(default=False)
|
| 66 |
+
registro_allowlist: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 67 |
+
|
| 68 |
# --- Base de datos (usuarios). Ruta fuera del webroot. ---
|
| 69 |
db_path: Path = Field(default=RAIZ_REPO / "instance" / "morphos.db")
|
| 70 |
mysql_dsn: str = Field(default="") # si se define, se usa en vez de SQLite
|
|
|
|
| 250 |
lab_api_keys: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
| 251 |
# Persistencia opcional de resultados en SQLite (sólo útil con volumen persistente).
|
| 252 |
lab_persistir: bool = Field(default=False)
|
| 253 |
+
# Cola de muestras recibidas (`GET /api/lab/pendientes`). DESACTIVADA por defecto: enumera
|
| 254 |
+
# TODAS las muestras del almacén —que no está segmentado por clínica ni por usuario— y cada
|
| 255 |
+
# `muestra_id` que devuelve abre `GET /api/lab/resultados`, o sea el panel completo de
|
| 256 |
+
# analitos más las pistas de paciente (nombre de la mascota, raza, sexo). Cualquier sesión
|
| 257 |
+
# la podía llamar.
|
| 258 |
+
#
|
| 259 |
+
# Apagarla NO cierra el agujero y no hay que venderlo así: el `muestra_id` lo pone el
|
| 260 |
+
# analizador (el puente sólo lo recorta) y suele ser un correlativo corto, así que
|
| 261 |
+
# `/api/lab/resultados` sigue siendo enumerable a fuerza bruta dentro de
|
| 262 |
+
# `limite_lab_consulta`. Lo que se elimina es el volcado en UNA petición. El cierre real es
|
| 263 |
+
# atar cada resultado a un tenant y filtrar por la sesión (ver ARCHITECTURE_REVIEW §2.1).
|
| 264 |
+
#
|
| 265 |
+
# Se enciende en despliegues de una sola clínica, donde el conjunto de sesiones es el
|
| 266 |
+
# personal invitado. El frontend oculta el botón si el endpoint responde 404.
|
| 267 |
+
lab_pendientes_habilitado: bool = Field(default=False)
|
| 268 |
|
| 269 |
+
@field_validator("lab_api_keys", "modelos_locales", "registro_allowlist", mode="before")
|
| 270 |
@classmethod
|
| 271 |
def _dividir_lista(cls, v):
|
| 272 |
"""Acepta lista JSON o cadena separada por comas.
|
|
|
|
| 303 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 304 |
return permitidos
|
| 305 |
|
| 306 |
+
def emails_registro_permitidos(self) -> set[str]:
|
| 307 |
+
"""Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
|
| 308 |
+
return {e.strip().lower() for e in self.registro_allowlist if e.strip()}
|
| 309 |
+
|
| 310 |
+
def registro_permitido(self, email: str) -> bool:
|
| 311 |
+
"""Si este email puede darse de alta."""
|
| 312 |
+
if self.registro_abierto:
|
| 313 |
+
return True
|
| 314 |
+
return email.strip().lower() in self.emails_registro_permitidos()
|
| 315 |
+
|
| 316 |
+
def avisar_de_configuracion(self) -> None:
|
| 317 |
+
"""Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
|
| 318 |
+
if self.registro_abierto:
|
| 319 |
+
log.warning(
|
| 320 |
+
"MORPHOS_REGISTRO_ABIERTO=true: cualquiera puede crear una cuenta y gastar "
|
| 321 |
+
"cuota de modelo. Sólo para desarrollo local."
|
| 322 |
+
)
|
| 323 |
+
elif not self.emails_registro_permitidos():
|
| 324 |
+
# El caso que deja la instancia inservible tras un reinicio con `instance/` efímero.
|
| 325 |
+
log.warning(
|
| 326 |
+
"Alta de cuentas cerrada y MORPHOS_REGISTRO_ALLOWLIST vacía: nadie puede "
|
| 327 |
+
"registrarse. Si la base de usuarios está vacía, nadie podrá entrar."
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
def validar_prod(self) -> None:
|
| 331 |
"""Requisitos que sólo aplican en producción; falla cerrado si faltan."""
|
| 332 |
if self.entorno != "prod":
|
|
|
|
| 346 |
def obtener_config() -> Configuracion:
|
| 347 |
cfg = Configuracion()
|
| 348 |
cfg.validar_prod()
|
| 349 |
+
cfg.avisar_de_configuracion()
|
| 350 |
return cfg
|
|
@@ -93,6 +93,14 @@ async def login(request: Request, body: LoginBody, response: Response) -> dict:
|
|
| 93 |
@router.post("/auth/registro")
|
| 94 |
@limiter.limit(obtener_config().limite_login)
|
| 95 |
async def registro(request: Request, body: RegistroBody, response: Response) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if buscar_usuario(body.email):
|
| 97 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 98 |
crear_usuario(body.nombre, body.apellido, body.email, body.password)
|
|
|
|
| 93 |
@router.post("/auth/registro")
|
| 94 |
@limiter.limit(obtener_config().limite_login)
|
| 95 |
async def registro(request: Request, body: RegistroBody, response: Response) -> dict:
|
| 96 |
+
# ANTES que la comprobación de existencia, a propósito: si se hiciera después, un email
|
| 97 |
+
# fuera de la lista distinguiría «ya existe» (409) de «no existe» (403) y el alta se
|
| 98 |
+
# convertiría en un oráculo de qué cuentas hay. Fuera de la lista, siempre 403.
|
| 99 |
+
if not obtener_config().registro_permitido(body.email):
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status.HTTP_403_FORBIDDEN,
|
| 102 |
+
"El alta de cuentas está restringida. Solicita acceso al administrador.",
|
| 103 |
+
)
|
| 104 |
if buscar_usuario(body.email):
|
| 105 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 106 |
crear_usuario(body.nombre, body.apellido, body.email, body.password)
|
|
@@ -73,7 +73,16 @@ async def get_pendientes(
|
|
| 73 |
request: Request,
|
| 74 |
_sesion: dict = Depends(usuario_actual),
|
| 75 |
) -> list[ResumenPendiente]:
|
| 76 |
-
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
return [
|
| 78 |
ResumenPendiente(
|
| 79 |
muestra_id=r.muestra_id,
|
|
|
|
| 73 |
request: Request,
|
| 74 |
_sesion: dict = Depends(usuario_actual),
|
| 75 |
) -> list[ResumenPendiente]:
|
| 76 |
+
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
| 77 |
+
|
| 78 |
+
Desactivada por defecto (`MORPHOS_LAB_PENDIENTES_HABILITADO`): el almacén no está
|
| 79 |
+
segmentado, así que esto enumera las muestras de TODAS las clínicas y cada ID abre el panel
|
| 80 |
+
completo en `/lab/resultados`. 404 —y no 403— para que apagada sea indistinguible de no
|
| 81 |
+
existir. Ver el comentario en `config.py`: apagarla reduce el volcado masivo, no sustituye
|
| 82 |
+
al filtrado por tenant.
|
| 83 |
+
"""
|
| 84 |
+
if not obtener_config().lab_pendientes_habilitado:
|
| 85 |
+
raise HTTPException(status.HTTP_404_NOT_FOUND, "No disponible.")
|
| 86 |
return [
|
| 87 |
ResumenPendiente(
|
| 88 |
muestra_id=r.muestra_id,
|
|
@@ -12,9 +12,41 @@ import os
|
|
| 12 |
import tempfile
|
| 13 |
from pathlib import Path
|
| 14 |
|
|
|
|
|
|
|
| 15 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
| 16 |
os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
|
| 17 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 18 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 19 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
| 20 |
os.environ.setdefault("MORPHOS_ENTORNO", "dev")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import tempfile
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
| 18 |
os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
|
| 19 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 20 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 21 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
| 22 |
os.environ.setdefault("MORPHOS_ENTORNO", "dev")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture(autouse=True)
|
| 26 |
+
def _limitador_limpio():
|
| 27 |
+
"""Vacía el contador de rate limiting entre pruebas.
|
| 28 |
+
|
| 29 |
+
El limitador es un almacén en memoria de proceso (`security/rate_limit.py`) compartido por
|
| 30 |
+
toda la suite, y el TestClient sale siempre desde la misma IP ('testclient'), así que los
|
| 31 |
+
contadores se acumulan de una prueba a otra: `limite_login` (5/minute) cubre también
|
| 32 |
+
`/api/auth/registro`, y bastaba añadir pruebas de alta para que otras empezaran a recibir
|
| 33 |
+
429 según el ORDEN en que corrieran. Resetear aquí hace que cada prueba mida lo suyo.
|
| 34 |
+
"""
|
| 35 |
+
from app.security.rate_limit import limiter
|
| 36 |
+
|
| 37 |
+
limiter.reset()
|
| 38 |
+
yield
|
| 39 |
+
limiter.reset()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def alta_abierta(monkeypatch):
|
| 44 |
+
"""Abre el alta de cuentas para las pruebas que sólo necesitan una sesión.
|
| 45 |
+
|
| 46 |
+
El alta está CERRADA por defecto y así se queda en el resto de la suite: eso es lo que
|
| 47 |
+
comprueban las pruebas de `test_registro_cerrado.py`. Las que sólo quieren llegar a otro
|
| 48 |
+
endpoint piden este fixture en vez de repetir el monkeypatch.
|
| 49 |
+
"""
|
| 50 |
+
from app.config import obtener_config
|
| 51 |
+
|
| 52 |
+
monkeypatch.setattr(obtener_config(), "registro_abierto", True)
|
|
@@ -38,7 +38,7 @@ def test_cabeceras_seguridad_presentes(cliente):
|
|
| 38 |
assert r.headers.get("X-Frame-Options") == "DENY"
|
| 39 |
|
| 40 |
|
| 41 |
-
def test_flujo_registro_login_e_interpret(cliente, monkeypatch):
|
| 42 |
# Registro emite sesión + CSRF.
|
| 43 |
reg = cliente.post(
|
| 44 |
"/api/auth/registro",
|
|
@@ -81,7 +81,7 @@ def test_modelos_requiere_sesion(cliente):
|
|
| 81 |
assert cliente.get("/api/modelos").status_code == 401
|
| 82 |
|
| 83 |
|
| 84 |
-
def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente):
|
| 85 |
"""422 (error del cliente) y no 502: la petición es inválida, el modelo ni se llama."""
|
| 86 |
reg = cliente.post(
|
| 87 |
"/api/auth/registro",
|
|
@@ -100,7 +100,7 @@ def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente):
|
|
| 100 |
assert "no permitido" in r.text
|
| 101 |
|
| 102 |
|
| 103 |
-
def test_registro_rechaza_password_corta(cliente):
|
| 104 |
r = cliente.post(
|
| 105 |
"/api/auth/registro",
|
| 106 |
json={"nombre": "B", "apellido": "C", "email": "b@example.com", "password": "corta"},
|
|
|
|
| 38 |
assert r.headers.get("X-Frame-Options") == "DENY"
|
| 39 |
|
| 40 |
|
| 41 |
+
def test_flujo_registro_login_e_interpret(cliente, monkeypatch, alta_abierta):
|
| 42 |
# Registro emite sesión + CSRF.
|
| 43 |
reg = cliente.post(
|
| 44 |
"/api/auth/registro",
|
|
|
|
| 81 |
assert cliente.get("/api/modelos").status_code == 401
|
| 82 |
|
| 83 |
|
| 84 |
+
def test_interpret_rechaza_modelo_fuera_de_la_lista_blanca(cliente, alta_abierta):
|
| 85 |
"""422 (error del cliente) y no 502: la petición es inválida, el modelo ni se llama."""
|
| 86 |
reg = cliente.post(
|
| 87 |
"/api/auth/registro",
|
|
|
|
| 100 |
assert "no permitido" in r.text
|
| 101 |
|
| 102 |
|
| 103 |
+
def test_registro_rechaza_password_corta(cliente, alta_abierta):
|
| 104 |
r = cliente.post(
|
| 105 |
"/api/auth/registro",
|
| 106 |
json={"nombre": "B", "apellido": "C", "email": "b@example.com", "password": "corta"},
|
|
@@ -53,7 +53,7 @@ def test_ingesta_bearer_erroneo_es_401(cliente, monkeypatch):
|
|
| 53 |
assert r.status_code == 401
|
| 54 |
|
| 55 |
|
| 56 |
-
def test_ingesta_y_consulta_completa(cliente, monkeypatch):
|
| 57 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 58 |
|
| 59 |
# Ingesta con key válida.
|
|
@@ -89,10 +89,19 @@ def test_ingesta_rechaza_observaciones_vacias(cliente, monkeypatch):
|
|
| 89 |
|
| 90 |
|
| 91 |
def test_pendientes_requiere_sesion(cliente):
|
|
|
|
|
|
|
| 92 |
assert cliente.get("/api/lab/pendientes").status_code == 401
|
| 93 |
|
| 94 |
|
| 95 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 97 |
for muestra in ("PEND-1", "PEND-2"):
|
| 98 |
cliente.post(
|
|
|
|
| 53 |
assert r.status_code == 401
|
| 54 |
|
| 55 |
|
| 56 |
+
def test_ingesta_y_consulta_completa(cliente, monkeypatch, alta_abierta):
|
| 57 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 58 |
|
| 59 |
# Ingesta con key válida.
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
def test_pendientes_requiere_sesion(cliente):
|
| 92 |
+
"""401 antes que 404: la dependencia de sesión corre antes del cuerpo del endpoint, así que
|
| 93 |
+
la cola apagada no convierte esto en un endpoint anónimo."""
|
| 94 |
assert cliente.get("/api/lab/pendientes").status_code == 401
|
| 95 |
|
| 96 |
|
| 97 |
+
def test_pendientes_desactivada_por_defecto_es_404(cliente, alta_abierta):
|
| 98 |
+
"""Con sesión válida y la cola apagada (el defecto), 404: indistinguible de no existir."""
|
| 99 |
+
_con_sesion(cliente, email="pend-off@example.com")
|
| 100 |
+
assert cliente.get("/api/lab/pendientes").status_code == 404
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_pendientes_lista_mas_reciente_primero(cliente, monkeypatch, alta_abierta):
|
| 104 |
+
monkeypatch.setattr(obtener_config(), "lab_pendientes_habilitado", True)
|
| 105 |
monkeypatch.setattr(obtener_config(), "lab_api_keys", ["k-secreta"])
|
| 106 |
for muestra in ("PEND-1", "PEND-2"):
|
| 107 |
cliente.post(
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""El alta de cuentas está cerrada por defecto y sólo la abre una allowlist de emails.
|
| 2 |
+
|
| 3 |
+
Por qué importa: una cuenta alcanza `/api/interpret`, que gasta cuota de ZeroGPU compartida y,
|
| 4 |
+
por la ruta Claude, dinero real. Mientras el alta fue abierta, el techo por usuario
|
| 5 |
+
(`limite_interpret_usuario`) protegía una identidad que costaba una petición HTTP acuñar.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
|
| 13 |
+
from app.config import obtener_config
|
| 14 |
+
from app.main import app
|
| 15 |
+
|
| 16 |
+
ALTA = {"nombre": "Ana", "apellido": "Vet", "password": "clave-segura-1"}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def cliente():
|
| 21 |
+
with TestClient(app) as c:
|
| 22 |
+
yield c
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _alta(cliente, email):
|
| 26 |
+
return cliente.post("/api/auth/registro", json={**ALTA, "email": email})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_alta_cerrada_por_defecto(cliente):
|
| 30 |
+
"""Sin tocar nada: el defecto del servicio rechaza el alta."""
|
| 31 |
+
r = _alta(cliente, "desconocida@example.com")
|
| 32 |
+
assert r.status_code == 403
|
| 33 |
+
assert "restringida" in r.text
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_email_en_la_allowlist_puede_darse_de_alta(cliente, monkeypatch):
|
| 37 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", ["permitida@example.com"])
|
| 38 |
+
r = _alta(cliente, "permitida@example.com")
|
| 39 |
+
assert r.status_code == 200, r.text
|
| 40 |
+
assert r.json()["ok"] is True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_allowlist_ignora_mayusculas_y_espacios(cliente, monkeypatch):
|
| 44 |
+
"""El email entra por un formulario; comparar crudo dejaría fuera a un aprobado."""
|
| 45 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", [" Mixta@Example.COM "])
|
| 46 |
+
assert _alta(cliente, "mixta@example.com").status_code == 200
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_fuera_de_la_allowlist_no_revela_si_la_cuenta_existe(cliente, monkeypatch):
|
| 50 |
+
"""403 tanto si la cuenta existe como si no: el alta no puede ser un oráculo de cuentas.
|
| 51 |
+
|
| 52 |
+
Si la comprobación de allowlist fuera DESPUÉS de la de existencia, un email no aprobado
|
| 53 |
+
distinguiría 409 (existe) de 403 (no existe) y enumeraría la base de usuarios.
|
| 54 |
+
"""
|
| 55 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", ["existente@example.com"])
|
| 56 |
+
assert _alta(cliente, "existente@example.com").status_code == 200 # ya existe a partir de aquí
|
| 57 |
+
|
| 58 |
+
monkeypatch.setattr(obtener_config(), "registro_allowlist", [])
|
| 59 |
+
existente = _alta(cliente, "existente@example.com")
|
| 60 |
+
inexistente = _alta(cliente, "jamas-vista@example.com")
|
| 61 |
+
assert existente.status_code == 403
|
| 62 |
+
assert inexistente.status_code == 403
|
| 63 |
+
assert existente.text == inexistente.text
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_registro_abierto_deja_pasar_a_cualquiera(cliente, monkeypatch):
|
| 67 |
+
"""La vía de escape para desarrollo local sigue funcionando."""
|
| 68 |
+
monkeypatch.setattr(obtener_config(), "registro_abierto", True)
|
| 69 |
+
assert _alta(cliente, "cualquiera@example.com").status_code == 200
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_allowlist_admite_cadena_separada_por_comas(monkeypatch):
|
| 73 |
+
"""Forma documentada en .env.example; la de lista JSON ya la cubre el validador compartido."""
|
| 74 |
+
from app.config import Configuracion
|
| 75 |
+
|
| 76 |
+
cfg = Configuracion(registro_allowlist="uno@example.com, dos@example.com")
|
| 77 |
+
assert cfg.emails_registro_permitidos() == {"uno@example.com", "dos@example.com"}
|
| 78 |
+
assert cfg.registro_permitido("dos@example.com")
|
| 79 |
+
assert not cfg.registro_permitido("tres@example.com")
|
|
@@ -298,6 +298,13 @@ function inicializarCola(
|
|
| 298 |
mostrarToast('Inicia sesión para ver los resultados recibidos.', true);
|
| 299 |
return;
|
| 300 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
if (!resp.ok) {
|
| 302 |
mostrarToast('Error al consultar los resultados recibidos.', true);
|
| 303 |
return;
|
|
|
|
| 298 |
mostrarToast('Inicia sesión para ver los resultados recibidos.', true);
|
| 299 |
return;
|
| 300 |
}
|
| 301 |
+
// 404 = la cola está desactivada en el servidor (MORPHOS_LAB_PENDIENTES_HABILITADO).
|
| 302 |
+
// No es un error del usuario: se retira el botón en vez de dejarlo fallando.
|
| 303 |
+
if (resp.status === 404) {
|
| 304 |
+
btn.hidden = true;
|
| 305 |
+
lista.hidden = true;
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
if (!resp.ok) {
|
| 309 |
mostrarToast('Error al consultar los resultados recibidos.', true);
|
| 310 |
return;
|