Jose Salazar Claude Opus 5 commited on
Commit
61cd0db
·
1 Parent(s): 523cb7e

Cerrar el alta de cuentas y apagar la cola de muestras por defecto

Browse files

Contenció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 CHANGED
@@ -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:
backend/.env.example CHANGED
@@ -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
backend/app/config.py CHANGED
@@ -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
backend/app/routers/auth.py CHANGED
@@ -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)
backend/app/routers/lab.py CHANGED
@@ -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,
backend/tests/conftest.py CHANGED
@@ -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)
backend/tests/test_api.py CHANGED
@@ -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"},
backend/tests/test_lab_ingesta.py CHANGED
@@ -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 test_pendientes_lista_mas_reciente_primero(cliente, monkeypatch):
 
 
 
 
 
 
 
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(
backend/tests/test_registro_cerrado.py ADDED
@@ -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")
frontend/src/lab-import.ts CHANGED
@@ -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;