Spaces:
Sleeping
Aislar por clínica los resultados de analizador
Browse filesARCHITECTURE_REVIEW §2.1 (+ la parte de identidad de §2.9). Era el hallazgo más
grave de la revisión: `lab/almacen.py` era un dict global sin dueño y
`routers/lab.py` no filtraba nada, así que CUALQUIER sesión autenticada podía
leer la muestra de cualquier clínica, y `/api/lab/pendientes` las enumeraba todas
—sin necesidad siquiera de adivinar IDs—. Cada `muestra_id` abre el panel completo
de analitos más las pistas de paciente (nombre de la mascota, raza, sexo).
IDENTIDAD DE DISPOSITIVO. `lab_api_keys` pasa de lista de secretos anónimos a
`clinica:clave`. `verificar_dispositivo` devuelve el tenant dueño en vez de None,
y el recorrido sigue siendo en tiempo constante sobre TODAS las claves (un lookup
por diccionario sobre un secreto filtra por tiempo). Revocar = quitar la clave y
reiniciar.
DE DÓNDE SALE EL TENANT. Del servidor, siempre: de la API key en la ingesta y de
la cookie FIRMADA en la lectura. Nunca del cuerpo de la petición —si el puente
pudiera declarar su clínica, mentir en un campo bastaría para escribir en la de
otro— ni de un parámetro de consulta. Hay prueba de las dos cosas.
USUARIOS. Columna `tenant` (migración 3, estrenando el mecanismo del commit
anterior) asignada desde la allowlist (`email=clinica`), no desde el formulario.
COMPATIBILIDAD. Sin tenants declarados todo cae en "principal": dispositivos sin
prefijo, cuentas sin sufijo y las sesiones ya emitidas, que no llevan el campo en
la cookie y durarían hasta 8h. Un despliegue de una sola clínica —el caso actual—
no nota el cambio, y el aislamiento aparece en cuanto se declaran clínicas.
Una muestra de otra clínica devuelve 404, no 403: que exista tampoco es
información que se dé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +9 -6
- backend/.env.example +13 -6
- backend/app/config.py +41 -1
- backend/app/db.py +19 -10
- backend/app/lab/almacen.py +15 -4
- backend/app/routers/auth.py +15 -6
- backend/app/routers/lab.py +7 -6
- backend/app/schemas_lab.py +4 -0
- backend/app/security/authz.py +12 -0
- backend/app/security/device.py +11 -5
- backend/tests/test_tenant_lab.py +151 -0
|
@@ -119,12 +119,15 @@ Dos defectos que se cerraron y **no se vuelven a abrir sin sustituirlos por algo
|
|
| 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 |
-
- **
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
+
- **Los resultados de analizador están segmentados por CLÍNICA (tenant).** El tenant lo pone
|
| 123 |
+
siempre el servidor: de la API key del dispositivo en la ingesta (`clinica:clave`) y de la
|
| 124 |
+
cookie firmada en la lectura. **Nunca del cuerpo ni de un parámetro** — si el puente pudiera
|
| 125 |
+
declarar su clínica, mentir en un campo bastaría para escribir en la de otro. Una muestra de
|
| 126 |
+
otra clínica devuelve 404, no 403. Sin tenants declarados todo cae en `principal`, así que un
|
| 127 |
+
despliegue de una sola clínica no nota nada.
|
| 128 |
+
- **`GET /api/lab/pendientes` sigue apagado por defecto** (`lab_pendientes_habilitado=False` →
|
| 129 |
+
404). Ya no es un volcado global —sólo lista la clínica de la sesión—, pero dentro de ella
|
| 130 |
+
enumera todas las muestras, así que se enciende a propósito.
|
| 131 |
|
| 132 |
Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
|
| 133 |
`alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
|
|
@@ -18,7 +18,9 @@ MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
|
|
| 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 |
-
#
|
|
|
|
|
|
|
| 22 |
MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
|
| 23 |
|
| 24 |
# --- Base de datos de usuarios (SQLite fuera del webroot) ---
|
|
@@ -81,12 +83,17 @@ MORPHOS_RAG_MAX_POR_LIBRO=2
|
|
| 81 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 82 |
|
| 83 |
# --- Integración de analizadores de laboratorio ---
|
| 84 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
|
| 86 |
-
# UI.
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
#
|
| 90 |
MORPHOS_LAB_PENDIENTES_HABILITADO=false
|
| 91 |
|
| 92 |
# --- Proxy inverso ---
|
|
|
|
| 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 |
+
# Formato: `email` o `email=clinica`. La clínica (tenant) decide qué resultados de analizador
|
| 22 |
+
# ve esa cuenta; sin sufijo, la de por defecto ("principal").
|
| 23 |
+
# MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@otra.com=clinica-b
|
| 24 |
MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
|
| 25 |
|
| 26 |
# --- Base de datos de usuarios (SQLite fuera del webroot) ---
|
|
|
|
| 83 |
# MORPHOS_RAG_SCORE_MINIMO=
|
| 84 |
|
| 85 |
# --- Integración de analizadores de laboratorio ---
|
| 86 |
+
# Formato: `clave` o `clinica:clave`. La clínica es la DUEÑA de lo que ingiera ese dispositivo,
|
| 87 |
+
# y sólo las sesiones de esa misma clínica pueden leerlo. Sin prefijo, la de por defecto
|
| 88 |
+
# ("principal"), que es lo que quiere un despliegue de una sola clínica.
|
| 89 |
+
# El puente NO puede declarar su clínica en el cuerpo: sale de la clave, en el servidor.
|
| 90 |
+
# Revocar un dispositivo = quitar su clave de aquí y reiniciar.
|
| 91 |
+
# MORPHOS_LAB_API_KEYS=clinica-a:clave-del-puente-1,clinica-b:clave-del-puente-2
|
| 92 |
# Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
|
| 93 |
+
# UI. Desde el filtrado por clínica sólo enumera las muestras de la clínica de la sesión, así
|
| 94 |
+
# que ya no es un volcado global. Sigue DESACTIVADA por defecto porque dentro de una misma
|
| 95 |
+
# clínica lista todas las muestras de todos sus pacientes, que es más de lo que necesita la
|
| 96 |
+
# mayoría de flujos; enciéndela si quieres el botón.
|
| 97 |
MORPHOS_LAB_PENDIENTES_HABILITADO=false
|
| 98 |
|
| 99 |
# --- Proxy inverso ---
|
|
@@ -7,6 +7,7 @@ lo necesario para una función concreta.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import logging
|
| 11 |
import os
|
| 12 |
from functools import lru_cache
|
|
@@ -24,6 +25,11 @@ RAIZ_REPO = Path(__file__).resolve().parents[2]
|
|
| 24 |
# Punto de montaje del almacenamiento persistente en HF Spaces.
|
| 25 |
VOLUMEN_PERSISTENTE = Path("/data")
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
def _ruta_db_por_defecto() -> Path:
|
| 29 |
"""Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.
|
|
@@ -344,9 +350,19 @@ class Configuracion(BaseSettings):
|
|
| 344 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 345 |
return permitidos
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
def emails_registro_permitidos(self) -> set[str]:
|
| 348 |
"""Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
|
| 349 |
-
return
|
| 350 |
|
| 351 |
def registro_permitido(self, email: str) -> bool:
|
| 352 |
"""Si este email puede darse de alta."""
|
|
@@ -354,6 +370,30 @@ class Configuracion(BaseSettings):
|
|
| 354 |
return True
|
| 355 |
return email.strip().lower() in self.emails_registro_permitidos()
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
def avisar_de_configuracion(self) -> None:
|
| 358 |
"""Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
|
| 359 |
if self.registro_abierto:
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import hmac
|
| 11 |
import logging
|
| 12 |
import os
|
| 13 |
from functools import lru_cache
|
|
|
|
| 25 |
# Punto de montaje del almacenamiento persistente en HF Spaces.
|
| 26 |
VOLUMEN_PERSISTENTE = Path("/data")
|
| 27 |
|
| 28 |
+
# Clínica a la que pertenecen los dispositivos y usuarios que no declaran otra. Un despliegue de
|
| 29 |
+
# una sola clínica —el caso normal— se queda entero aquí y no nota el cambio; el aislamiento
|
| 30 |
+
# aparece en cuanto se declaran tenants distintos.
|
| 31 |
+
TENANT_POR_DEFECTO = "principal"
|
| 32 |
+
|
| 33 |
|
| 34 |
def _ruta_db_por_defecto() -> Path:
|
| 35 |
"""Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.
|
|
|
|
| 350 |
permitidos[nombre] = modo.strip().lower() == "prosa"
|
| 351 |
return permitidos
|
| 352 |
|
| 353 |
+
def _allowlist_con_tenant(self) -> dict[str, str]:
|
| 354 |
+
"""email → tenant. Formato `email` o `email=tenant`; sin sufijo, TENANT_POR_DEFECTO."""
|
| 355 |
+
mapa: dict[str, str] = {}
|
| 356 |
+
for entrada in self.registro_allowlist:
|
| 357 |
+
email, _, tenant = entrada.partition("=")
|
| 358 |
+
email = email.strip().lower()
|
| 359 |
+
if email:
|
| 360 |
+
mapa[email] = tenant.strip() or TENANT_POR_DEFECTO
|
| 361 |
+
return mapa
|
| 362 |
+
|
| 363 |
def emails_registro_permitidos(self) -> set[str]:
|
| 364 |
"""Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
|
| 365 |
+
return set(self._allowlist_con_tenant())
|
| 366 |
|
| 367 |
def registro_permitido(self, email: str) -> bool:
|
| 368 |
"""Si este email puede darse de alta."""
|
|
|
|
| 370 |
return True
|
| 371 |
return email.strip().lower() in self.emails_registro_permitidos()
|
| 372 |
|
| 373 |
+
def tenant_de_email(self, email: str) -> str:
|
| 374 |
+
"""Clínica a la que pertenece un email al darse de alta.
|
| 375 |
+
|
| 376 |
+
Con el alta abierta (desarrollo) todo el mundo cae en el tenant por defecto: no hay
|
| 377 |
+
ninguna declaración de la que deducir otra cosa.
|
| 378 |
+
"""
|
| 379 |
+
return self._allowlist_con_tenant().get(email.strip().lower(), TENANT_POR_DEFECTO)
|
| 380 |
+
|
| 381 |
+
def tenant_de_clave_dispositivo(self, token: str) -> str | None:
|
| 382 |
+
"""Tenant dueño de esta API key de dispositivo, o None si no es válida.
|
| 383 |
+
|
| 384 |
+
Recorre TODAS las claves con `compare_digest` en vez de indexar un diccionario: un
|
| 385 |
+
lookup por hash sobre un secreto filtra por tiempo si coincide el prefijo, y esta
|
| 386 |
+
comparación es la única barrera de la ingesta.
|
| 387 |
+
"""
|
| 388 |
+
encontrado: str | None = None
|
| 389 |
+
for entrada in self.lab_api_keys:
|
| 390 |
+
tenant, sep, clave = entrada.partition(":")
|
| 391 |
+
if not sep:
|
| 392 |
+
tenant, clave = TENANT_POR_DEFECTO, entrada
|
| 393 |
+
if hmac.compare_digest(token, clave.strip()):
|
| 394 |
+
encontrado = tenant.strip() or TENANT_POR_DEFECTO
|
| 395 |
+
return encontrado
|
| 396 |
+
|
| 397 |
def avisar_de_configuracion(self) -> None:
|
| 398 |
"""Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
|
| 399 |
if self.registro_abierto:
|
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Diferencias de seguridad frente a la versión PHP:
|
| 4 |
- La BD SQLite vive en instance/ FUERA del directorio servido (no es descargable).
|
| 5 |
-
-
|
| 6 |
- Hash de contraseña con scrypt (stdlib), sal aleatoria por usuario.
|
| 7 |
"""
|
| 8 |
|
|
@@ -16,7 +16,7 @@ import sqlite3
|
|
| 16 |
from collections.abc import Iterator
|
| 17 |
from contextlib import contextmanager
|
| 18 |
|
| 19 |
-
from .config import VOLUMEN_PERSISTENTE, obtener_config
|
| 20 |
|
| 21 |
log = logging.getLogger("morphos.db")
|
| 22 |
|
|
@@ -26,10 +26,11 @@ log = logging.getLogger("morphos.db")
|
|
| 26 |
# Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
|
| 27 |
# resultante, y sólo se aplican los pasos por encima de la versión actual.
|
| 28 |
#
|
| 29 |
-
# Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo)
|
| 30 |
-
# los pasos se añaden al final
|
| 31 |
-
#
|
| 32 |
-
#
|
|
|
|
| 33 |
_MIGRACIONES: list[str] = [
|
| 34 |
# 1 — esquema inicial (el que ya existía).
|
| 35 |
"""
|
|
@@ -60,6 +61,12 @@ CREATE TABLE IF NOT EXISTS resultados_lab (
|
|
| 60 |
"""
|
| 61 |
CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
|
| 62 |
ON intentos_login (email, ip, momento);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
""",
|
| 64 |
]
|
| 65 |
|
|
@@ -127,17 +134,19 @@ def verificar_password(password: str, almacenado: str) -> bool:
|
|
| 127 |
def buscar_usuario(email: str) -> sqlite3.Row | None:
|
| 128 |
with _conexion() as con:
|
| 129 |
cur = con.execute(
|
| 130 |
-
"SELECT id, nombre, apellido, email, password
|
|
|
|
| 131 |
(email,),
|
| 132 |
)
|
| 133 |
return cur.fetchone()
|
| 134 |
|
| 135 |
|
| 136 |
-
def crear_usuario(nombre: str, apellido: str, email: str, password: str) -> None:
|
| 137 |
with _conexion() as con:
|
| 138 |
con.execute(
|
| 139 |
-
"INSERT INTO usuarios (nombre, apellido, email, password
|
| 140 |
-
(
|
|
|
|
| 141 |
)
|
| 142 |
|
| 143 |
|
|
|
|
| 2 |
|
| 3 |
Diferencias de seguridad frente a la versión PHP:
|
| 4 |
- La BD SQLite vive en instance/ FUERA del directorio servido (no es descargable).
|
| 5 |
+
- Esquema versionado con `PRAGMA user_version` (ver `_MIGRACIONES`).
|
| 6 |
- Hash de contraseña con scrypt (stdlib), sal aleatoria por usuario.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 16 |
from collections.abc import Iterator
|
| 17 |
from contextlib import contextmanager
|
| 18 |
|
| 19 |
+
from .config import TENANT_POR_DEFECTO, VOLUMEN_PERSISTENTE, obtener_config
|
| 20 |
|
| 21 |
log = logging.getLogger("morphos.db")
|
| 22 |
|
|
|
|
| 26 |
# Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
|
| 27 |
# resultante, y sólo se aplican los pasos por encima de la versión actual.
|
| 28 |
#
|
| 29 |
+
# Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo) y
|
| 30 |
+
# los pasos se añaden al final. La versión garantiza que cada paso corre UNA vez, así que no
|
| 31 |
+
# tienen por qué ser idempotentes —el 3 es un ALTER TABLE, que no lo es—. Los `IF NOT EXISTS`
|
| 32 |
+
# del paso 1 son por otro motivo: las BD creadas antes de este mecanismo están en la versión 0
|
| 33 |
+
# con esas tablas ya presentes, y hay que poder ponerlas al día sin borrarlas.
|
| 34 |
_MIGRACIONES: list[str] = [
|
| 35 |
# 1 — esquema inicial (el que ya existía).
|
| 36 |
"""
|
|
|
|
| 61 |
"""
|
| 62 |
CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
|
| 63 |
ON intentos_login (email, ip, momento);
|
| 64 |
+
""",
|
| 65 |
+
# 3 — clínica dueña de cada cuenta. Los usuarios que ya existan quedan en el tenant por
|
| 66 |
+
# defecto, que es donde también caen los dispositivos sin clínica declarada: un despliegue
|
| 67 |
+
# de una sola clínica no nota el cambio.
|
| 68 |
+
f"""
|
| 69 |
+
ALTER TABLE usuarios ADD COLUMN tenant TEXT NOT NULL DEFAULT '{TENANT_POR_DEFECTO}';
|
| 70 |
""",
|
| 71 |
]
|
| 72 |
|
|
|
|
| 134 |
def buscar_usuario(email: str) -> sqlite3.Row | None:
|
| 135 |
with _conexion() as con:
|
| 136 |
cur = con.execute(
|
| 137 |
+
"SELECT id, nombre, apellido, email, password, tenant "
|
| 138 |
+
"FROM usuarios WHERE email = ? LIMIT 1",
|
| 139 |
(email,),
|
| 140 |
)
|
| 141 |
return cur.fetchone()
|
| 142 |
|
| 143 |
|
| 144 |
+
def crear_usuario(nombre: str, apellido: str, email: str, password: str, tenant: str) -> None:
|
| 145 |
with _conexion() as con:
|
| 146 |
con.execute(
|
| 147 |
+
"INSERT INTO usuarios (nombre, apellido, email, password, tenant) "
|
| 148 |
+
"VALUES (?, ?, ?, ?, ?)",
|
| 149 |
+
(nombre, apellido, email, hash_password(password), tenant),
|
| 150 |
)
|
| 151 |
|
| 152 |
|
|
@@ -1,5 +1,8 @@
|
|
| 1 |
"""Almacén en proceso de resultados de analizador, emparejados por ID de muestra.
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
Deliberadamente NO es SQLite: HF Spaces tiene disco efímero, un único worker de uvicorn, y
|
| 4 |
los resultados son de vida corta (se emparejan con el formulario en minutos). Un dict con
|
| 5 |
TTL + tope LRU es la primitiva correcta. Clave normalizada (trim + minúsculas) en lectura y
|
|
@@ -40,16 +43,24 @@ class AlmacenResultados:
|
|
| 40 |
while len(self._datos) > self._max:
|
| 41 |
self._datos.popitem(last=False) # desaloja el más antiguo
|
| 42 |
|
| 43 |
-
def obtener(self, muestra_id: str) -> ResultadoMapeado | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
with self._lock:
|
| 45 |
self._barrer_locked()
|
| 46 |
item = self._datos.get(_clave(muestra_id))
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
def pendientes(self) -> list[ResultadoMapeado]:
|
|
|
|
| 50 |
with self._lock:
|
| 51 |
self._barrer_locked()
|
| 52 |
-
return [r for (_, r) in reversed(self._datos.values())]
|
| 53 |
|
| 54 |
def _barrer_locked(self) -> None:
|
| 55 |
ahora = time.monotonic()
|
|
|
|
| 1 |
"""Almacén en proceso de resultados de analizador, emparejados por ID de muestra.
|
| 2 |
|
| 3 |
+
Segmentado por TENANT: cada resultado lleva la clínica dueña y las lecturas la exigen. Antes era
|
| 4 |
+
un dict global y cualquier sesión autenticada podía leer la muestra de cualquier clínica.
|
| 5 |
+
|
| 6 |
Deliberadamente NO es SQLite: HF Spaces tiene disco efímero, un único worker de uvicorn, y
|
| 7 |
los resultados son de vida corta (se emparejan con el formulario en minutos). Un dict con
|
| 8 |
TTL + tope LRU es la primitiva correcta. Clave normalizada (trim + minúsculas) en lectura y
|
|
|
|
| 43 |
while len(self._datos) > self._max:
|
| 44 |
self._datos.popitem(last=False) # desaloja el más antiguo
|
| 45 |
|
| 46 |
+
def obtener(self, muestra_id: str, tenant: str) -> ResultadoMapeado | None:
|
| 47 |
+
"""Resultado de ESE tenant, o None. Un ID de otra clínica se comporta como inexistente.
|
| 48 |
+
|
| 49 |
+
El tenant es obligatorio a propósito: si fuera opcional, olvidarlo en una llamada nueva
|
| 50 |
+
devolvería datos de todas las clínicas en silencio, que es justo el fallo que esto cierra.
|
| 51 |
+
"""
|
| 52 |
with self._lock:
|
| 53 |
self._barrer_locked()
|
| 54 |
item = self._datos.get(_clave(muestra_id))
|
| 55 |
+
if item is None or item[1].tenant != tenant:
|
| 56 |
+
return None
|
| 57 |
+
return item[1]
|
| 58 |
|
| 59 |
+
def pendientes(self, tenant: str) -> list[ResultadoMapeado]:
|
| 60 |
+
"""Cola de ESE tenant, más recientes primero."""
|
| 61 |
with self._lock:
|
| 62 |
self._barrer_locked()
|
| 63 |
+
return [r for (_, r) in reversed(self._datos.values()) if r.tenant == tenant]
|
| 64 |
|
| 65 |
def _barrer_locked(self) -> None:
|
| 66 |
ahora = time.monotonic()
|
|
@@ -14,7 +14,7 @@ import asyncio
|
|
| 14 |
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
| 15 |
from pydantic import BaseModel, EmailStr, Field
|
| 16 |
|
| 17 |
-
from ..config import obtener_config
|
| 18 |
from ..db import (
|
| 19 |
buscar_usuario,
|
| 20 |
crear_usuario,
|
|
@@ -50,9 +50,11 @@ class RegistroBody(BaseModel):
|
|
| 50 |
password: str = Field(min_length=8, max_length=200)
|
| 51 |
|
| 52 |
|
| 53 |
-
def _emitir_sesion(resp: Response, email: str, nombre: str) -> str:
|
| 54 |
cfg = obtener_config()
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
csrf = nuevo_token_csrf()
|
| 57 |
resp.set_cookie(
|
| 58 |
COOKIE_SESION, token, httponly=True, secure=cfg.cookie_secure,
|
|
@@ -94,7 +96,9 @@ async def login(request: Request, body: LoginBody, response: Response) -> dict:
|
|
| 94 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Email o contraseña incorrectos.")
|
| 95 |
|
| 96 |
await asyncio.to_thread(limpiar_intentos, body.email)
|
| 97 |
-
csrf = _emitir_sesion(
|
|
|
|
|
|
|
| 98 |
return {"ok": True, "nombre": usuario["nombre"], "csrf": csrf}
|
| 99 |
|
| 100 |
|
|
@@ -112,8 +116,13 @@ async def registro(request: Request, body: RegistroBody, response: Response) ->
|
|
| 112 |
if await asyncio.to_thread(buscar_usuario, body.email):
|
| 113 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 114 |
# `crear_usuario` hashea con scrypt además de escribir: doble motivo para salir del bucle.
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return {"ok": True, "nombre": body.nombre, "csrf": csrf}
|
| 118 |
|
| 119 |
|
|
|
|
| 14 |
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
| 15 |
from pydantic import BaseModel, EmailStr, Field
|
| 16 |
|
| 17 |
+
from ..config import TENANT_POR_DEFECTO, obtener_config
|
| 18 |
from ..db import (
|
| 19 |
buscar_usuario,
|
| 20 |
crear_usuario,
|
|
|
|
| 50 |
password: str = Field(min_length=8, max_length=200)
|
| 51 |
|
| 52 |
|
| 53 |
+
def _emitir_sesion(resp: Response, email: str, nombre: str, tenant: str) -> str:
|
| 54 |
cfg = obtener_config()
|
| 55 |
+
# El tenant viaja en la cookie FIRMADA: el cliente no puede cambiarlo sin romper la firma,
|
| 56 |
+
# y así leerlo no cuesta una consulta a la BD en cada petición.
|
| 57 |
+
token = firmar_sesion({"email": email, "nombre": nombre, "tenant": tenant})
|
| 58 |
csrf = nuevo_token_csrf()
|
| 59 |
resp.set_cookie(
|
| 60 |
COOKIE_SESION, token, httponly=True, secure=cfg.cookie_secure,
|
|
|
|
| 96 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Email o contraseña incorrectos.")
|
| 97 |
|
| 98 |
await asyncio.to_thread(limpiar_intentos, body.email)
|
| 99 |
+
csrf = _emitir_sesion(
|
| 100 |
+
response, usuario["email"], usuario["nombre"], usuario["tenant"] or TENANT_POR_DEFECTO
|
| 101 |
+
)
|
| 102 |
return {"ok": True, "nombre": usuario["nombre"], "csrf": csrf}
|
| 103 |
|
| 104 |
|
|
|
|
| 116 |
if await asyncio.to_thread(buscar_usuario, body.email):
|
| 117 |
raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
|
| 118 |
# `crear_usuario` hashea con scrypt además de escribir: doble motivo para salir del bucle.
|
| 119 |
+
# La clínica sale de la allowlist (`email=tenant`), no del cuerpo de la petición: dejar
|
| 120 |
+
# elegir tenant al que se registra permitiría entrar en los datos de otra clínica.
|
| 121 |
+
tenant = obtener_config().tenant_de_email(body.email)
|
| 122 |
+
await asyncio.to_thread(
|
| 123 |
+
crear_usuario, body.nombre, body.apellido, body.email, body.password, tenant
|
| 124 |
+
)
|
| 125 |
+
csrf = _emitir_sesion(response, body.email, body.nombre, tenant)
|
| 126 |
return {"ok": True, "nombre": body.nombre, "csrf": csrf}
|
| 127 |
|
| 128 |
|
|
@@ -24,7 +24,7 @@ from ..schemas_lab import (
|
|
| 24 |
ResultadoMapeado,
|
| 25 |
ResumenPendiente,
|
| 26 |
)
|
| 27 |
-
from ..security.authz import usuario_actual
|
| 28 |
from ..security.device import verificar_dispositivo
|
| 29 |
from ..security.rate_limit import limiter
|
| 30 |
|
|
@@ -36,9 +36,10 @@ router = APIRouter()
|
|
| 36 |
async def post_ingesta(
|
| 37 |
request: Request, # requerido por slowapi
|
| 38 |
cuerpo: ResultadoAnalizador,
|
| 39 |
-
|
| 40 |
) -> RespuestaIngesta:
|
| 41 |
mapeado = mapear_resultado(cuerpo)
|
|
|
|
| 42 |
almacen.guardar(mapeado)
|
| 43 |
if obtener_config().lab_persistir:
|
| 44 |
# A un hilo como el resto de SQLite: el analizador manda en ráfaga
|
|
@@ -61,9 +62,9 @@ async def post_ingesta(
|
|
| 61 |
async def get_resultados(
|
| 62 |
request: Request,
|
| 63 |
muestra: str = Query(..., min_length=1, max_length=128),
|
| 64 |
-
|
| 65 |
) -> ResultadoMapeado:
|
| 66 |
-
res = almacen.obtener(muestra)
|
| 67 |
if res is None:
|
| 68 |
raise HTTPException(
|
| 69 |
status.HTTP_404_NOT_FOUND,
|
|
@@ -76,7 +77,7 @@ async def get_resultados(
|
|
| 76 |
@limiter.limit(obtener_config().limite_lab_consulta)
|
| 77 |
async def get_pendientes(
|
| 78 |
request: Request,
|
| 79 |
-
|
| 80 |
) -> list[ResumenPendiente]:
|
| 81 |
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
| 82 |
|
|
@@ -96,5 +97,5 @@ async def get_pendientes(
|
|
| 96 |
analitos=len(r.analitos),
|
| 97 |
no_mapeados=len(r.no_mapeados),
|
| 98 |
)
|
| 99 |
-
for r in almacen.pendientes()
|
| 100 |
]
|
|
|
|
| 24 |
ResultadoMapeado,
|
| 25 |
ResumenPendiente,
|
| 26 |
)
|
| 27 |
+
from ..security.authz import tenant_de_sesion, usuario_actual
|
| 28 |
from ..security.device import verificar_dispositivo
|
| 29 |
from ..security.rate_limit import limiter
|
| 30 |
|
|
|
|
| 36 |
async def post_ingesta(
|
| 37 |
request: Request, # requerido por slowapi
|
| 38 |
cuerpo: ResultadoAnalizador,
|
| 39 |
+
tenant: str = Depends(verificar_dispositivo), # 503 sin keys / 401 sin Bearer válido
|
| 40 |
) -> RespuestaIngesta:
|
| 41 |
mapeado = mapear_resultado(cuerpo)
|
| 42 |
+
mapeado.tenant = tenant # del servidor, a partir de la API key; nunca del cuerpo
|
| 43 |
almacen.guardar(mapeado)
|
| 44 |
if obtener_config().lab_persistir:
|
| 45 |
# A un hilo como el resto de SQLite: el analizador manda en ráfaga
|
|
|
|
| 62 |
async def get_resultados(
|
| 63 |
request: Request,
|
| 64 |
muestra: str = Query(..., min_length=1, max_length=128),
|
| 65 |
+
sesion: dict = Depends(usuario_actual), # 401 si no hay sesión
|
| 66 |
) -> ResultadoMapeado:
|
| 67 |
+
res = almacen.obtener(muestra, tenant_de_sesion(sesion))
|
| 68 |
if res is None:
|
| 69 |
raise HTTPException(
|
| 70 |
status.HTTP_404_NOT_FOUND,
|
|
|
|
| 77 |
@limiter.limit(obtener_config().limite_lab_consulta)
|
| 78 |
async def get_pendientes(
|
| 79 |
request: Request,
|
| 80 |
+
sesion: dict = Depends(usuario_actual),
|
| 81 |
) -> list[ResumenPendiente]:
|
| 82 |
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
|
| 83 |
|
|
|
|
| 97 |
analitos=len(r.analitos),
|
| 98 |
no_mapeados=len(r.no_mapeados),
|
| 99 |
)
|
| 100 |
+
for r in almacen.pendientes(tenant_de_sesion(sesion))
|
| 101 |
]
|
|
@@ -94,6 +94,10 @@ class ValorAnalito(BaseModel):
|
|
| 94 |
class ResultadoMapeado(BaseModel):
|
| 95 |
"""Resultado listo para el frontend: analitos por clave canónica + no reconocidos."""
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
muestra_id: str
|
| 98 |
instrumento_id: str
|
| 99 |
momento: datetime
|
|
|
|
| 94 |
class ResultadoMapeado(BaseModel):
|
| 95 |
"""Resultado listo para el frontend: analitos por clave canónica + no reconocidos."""
|
| 96 |
|
| 97 |
+
# Clínica dueña del resultado. Lo pone el SERVIDOR a partir de la API key del dispositivo
|
| 98 |
+
# (`verificar_dispositivo`), nunca el cuerpo de la ingesta: si el puente pudiera declararlo,
|
| 99 |
+
# mentir en un campo bastaría para escribir en la clínica de otro.
|
| 100 |
+
tenant: str = ""
|
| 101 |
muestra_id: str
|
| 102 |
instrumento_id: str
|
| 103 |
momento: datetime
|
|
@@ -10,6 +10,7 @@ import hmac
|
|
| 10 |
|
| 11 |
from fastapi import Cookie, Header, HTTPException, Request, status
|
| 12 |
|
|
|
|
| 13 |
from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion
|
| 14 |
|
| 15 |
|
|
@@ -22,6 +23,17 @@ def usuario_actual(request: Request) -> dict:
|
|
| 22 |
return sesion
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
def verificar_csrf(
|
| 26 |
request: Request,
|
| 27 |
x_csrf_token: str | None = Header(default=None, alias=CABECERA_CSRF),
|
|
|
|
| 10 |
|
| 11 |
from fastapi import Cookie, Header, HTTPException, Request, status
|
| 12 |
|
| 13 |
+
from ..config import TENANT_POR_DEFECTO
|
| 14 |
from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion
|
| 15 |
|
| 16 |
|
|
|
|
| 23 |
return sesion
|
| 24 |
|
| 25 |
|
| 26 |
+
def tenant_de_sesion(sesion: dict) -> str:
|
| 27 |
+
"""Clínica del usuario de la sesión.
|
| 28 |
+
|
| 29 |
+
Las sesiones emitidas ANTES de que existiera el tenant no lo llevan en la cookie firmada, y
|
| 30 |
+
duran hasta `session_max_age_s`. Caen al tenant por defecto, que es donde también viven los
|
| 31 |
+
dispositivos que no declaran clínica: un despliegue de una sola clínica —el caso normal—
|
| 32 |
+
sigue funcionando durante la transición sin que nadie tenga que volver a entrar.
|
| 33 |
+
"""
|
| 34 |
+
return sesion.get("tenant") or TENANT_POR_DEFECTO
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def verificar_csrf(
|
| 38 |
request: Request,
|
| 39 |
x_csrf_token: str | None = Header(default=None, alias=CABECERA_CSRF),
|
|
@@ -7,15 +7,19 @@ verificar_password en db.py). Falla cerrado: sin keys configuradas, la ingesta n
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import hmac
|
| 11 |
-
|
| 12 |
from fastapi import Header, HTTPException, status
|
| 13 |
|
| 14 |
from ..config import obtener_config
|
| 15 |
|
| 16 |
|
| 17 |
-
def verificar_dispositivo(authorization: str | None = Header(default=None)) ->
|
| 18 |
-
"""Dependencia para /api/lab/ingesta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
cfg = obtener_config()
|
| 20 |
if not cfg.lab_api_keys:
|
| 21 |
raise HTTPException(
|
|
@@ -25,5 +29,7 @@ def verificar_dispositivo(authorization: str | None = Header(default=None)) -> N
|
|
| 25 |
token = ""
|
| 26 |
if authorization and authorization.lower().startswith("bearer "):
|
| 27 |
token = authorization[7:].strip()
|
| 28 |
-
|
|
|
|
| 29 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Dispositivo no autorizado.")
|
|
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from fastapi import Header, HTTPException, status
|
| 11 |
|
| 12 |
from ..config import obtener_config
|
| 13 |
|
| 14 |
|
| 15 |
+
def verificar_dispositivo(authorization: str | None = Header(default=None)) -> str:
|
| 16 |
+
"""Dependencia para /api/lab/ingesta: devuelve el TENANT dueño de la clave.
|
| 17 |
+
|
| 18 |
+
Antes devolvía None y la clave era anónima: cualquier dispositivo escribía en un almacén
|
| 19 |
+
global y cualquier sesión lo leía. El tenant sale de la configuración del servidor, nunca
|
| 20 |
+
del cuerpo de la petición: si el puente pudiera declarar su clínica, bastaría mentir en un
|
| 21 |
+
campo para escribir en la de otro.
|
| 22 |
+
"""
|
| 23 |
cfg = obtener_config()
|
| 24 |
if not cfg.lab_api_keys:
|
| 25 |
raise HTTPException(
|
|
|
|
| 29 |
token = ""
|
| 30 |
if authorization and authorization.lower().startswith("bearer "):
|
| 31 |
token = authorization[7:].strip()
|
| 32 |
+
tenant = cfg.tenant_de_clave_dispositivo(token) if token else None
|
| 33 |
+
if tenant is None:
|
| 34 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Dispositivo no autorizado.")
|
| 35 |
+
return tenant
|
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aislamiento por clínica de los resultados de analizador (ARCHITECTURE_REVIEW §2.1).
|
| 2 |
+
|
| 3 |
+
El almacén era un dict global sin dueño: cualquier sesión autenticada podía leer la muestra de
|
| 4 |
+
cualquier clínica, y `/api/lab/pendientes` enumeraba TODAS. Cada `muestra_id` abre en
|
| 5 |
+
`/api/lab/resultados` el panel completo más las pistas de paciente (nombre de la mascota, raza,
|
| 6 |
+
sexo), así que el aislamiento es lo único que separa a una clínica de los datos de otra.
|
| 7 |
+
|
| 8 |
+
Dos cosas que estas pruebas fijan y no pueden relajarse: el tenant lo pone el SERVIDOR (de la API
|
| 9 |
+
key en la ingesta, de la cookie firmada en la lectura) y una muestra de otra clínica se comporta
|
| 10 |
+
como inexistente, no como prohibida.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
from fastapi.testclient import TestClient
|
| 17 |
+
|
| 18 |
+
from app.config import TENANT_POR_DEFECTO, obtener_config
|
| 19 |
+
from app.lab.almacen import almacen
|
| 20 |
+
from app.main import app
|
| 21 |
+
|
| 22 |
+
CLAVE_A = "clave-de-la-clinica-a"
|
| 23 |
+
CLAVE_B = "clave-de-la-clinica-b"
|
| 24 |
+
|
| 25 |
+
PAYLOAD = {
|
| 26 |
+
"muestra_id": "M-1",
|
| 27 |
+
"instrumento_id": "vetscan-1",
|
| 28 |
+
"fabricante": "Abaxis",
|
| 29 |
+
"observaciones": [{"codigo_prueba": "GLU", "valor": "5.0", "unidad": "mmol/L"}],
|
| 30 |
+
"momento": "2026-07-25T10:00:00Z",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture
|
| 35 |
+
def cliente():
|
| 36 |
+
almacen._datos.clear()
|
| 37 |
+
with TestClient(app) as c:
|
| 38 |
+
yield c
|
| 39 |
+
almacen._datos.clear()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.fixture
|
| 43 |
+
def dos_clinicas(monkeypatch):
|
| 44 |
+
"""Dos dispositivos y dos cuentas, cada uno en su clínica."""
|
| 45 |
+
cfg = obtener_config()
|
| 46 |
+
monkeypatch.setattr(cfg, "lab_api_keys", [f"clinica-a:{CLAVE_A}", f"clinica-b:{CLAVE_B}"])
|
| 47 |
+
monkeypatch.setattr(
|
| 48 |
+
cfg, "registro_allowlist", ["a@example.com=clinica-a", "b@example.com=clinica-b"]
|
| 49 |
+
)
|
| 50 |
+
monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _ingerir(cliente, clave, muestra):
|
| 54 |
+
return cliente.post(
|
| 55 |
+
"/api/lab/ingesta",
|
| 56 |
+
json={**PAYLOAD, "muestra_id": muestra},
|
| 57 |
+
headers={"Authorization": f"Bearer {clave}"},
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _sesion(cliente, email):
|
| 62 |
+
"""Sesión para ese email. La BD de pruebas se comparte, así que si la cuenta ya existe de
|
| 63 |
+
otra prueba se entra en vez de darla de alta (el tenant quedó fijado al crearla)."""
|
| 64 |
+
r = cliente.post(
|
| 65 |
+
"/api/auth/registro",
|
| 66 |
+
json={"nombre": "V", "apellido": "Vet", "email": email, "password": "clave-segura-1"},
|
| 67 |
+
)
|
| 68 |
+
if r.status_code == 409:
|
| 69 |
+
r = cliente.post(
|
| 70 |
+
"/api/auth/login", json={"email": email, "password": "clave-segura-1"}
|
| 71 |
+
)
|
| 72 |
+
assert r.status_code == 200, r.text
|
| 73 |
+
return r
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_una_clinica_no_ve_la_muestra_de_otra(cliente, dos_clinicas):
|
| 77 |
+
"""El hallazgo central de §2.1: cualquier sesión leía cualquier muestra."""
|
| 78 |
+
assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200
|
| 79 |
+
|
| 80 |
+
_sesion(cliente, "b@example.com")
|
| 81 |
+
r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"})
|
| 82 |
+
|
| 83 |
+
# 404 y no 403: existir o no en otra clínica tampoco es información que se dé.
|
| 84 |
+
assert r.status_code == 404
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_la_propia_clinica_si_la_ve(cliente, dos_clinicas):
|
| 88 |
+
"""El complemento obligatorio: aislar no puede romper el caso legítimo."""
|
| 89 |
+
assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200
|
| 90 |
+
|
| 91 |
+
_sesion(cliente, "a@example.com")
|
| 92 |
+
r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"})
|
| 93 |
+
|
| 94 |
+
assert r.status_code == 200
|
| 95 |
+
assert r.json()["muestra_id"] == "M-DE-A"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_pendientes_solo_enumera_lo_propio(cliente, dos_clinicas):
|
| 99 |
+
"""`pendientes` era el volcado en una petición: ahora sólo lista la clínica de la sesión."""
|
| 100 |
+
_ingerir(cliente, CLAVE_A, "M-DE-A")
|
| 101 |
+
_ingerir(cliente, CLAVE_B, "M-DE-B")
|
| 102 |
+
|
| 103 |
+
_sesion(cliente, "b@example.com")
|
| 104 |
+
r = cliente.get("/api/lab/pendientes")
|
| 105 |
+
|
| 106 |
+
assert r.status_code == 200
|
| 107 |
+
ids = [x["muestra_id"] for x in r.json()]
|
| 108 |
+
assert ids == ["M-DE-B"]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_el_puente_no_puede_declarar_su_clinica(cliente, dos_clinicas):
|
| 112 |
+
"""El tenant sale de la API key; si viniera del cuerpo, mentir bastaría para escribir en otra."""
|
| 113 |
+
r = cliente.post(
|
| 114 |
+
"/api/lab/ingesta",
|
| 115 |
+
json={**PAYLOAD, "muestra_id": "M-MENTIRA", "tenant": "clinica-b"},
|
| 116 |
+
headers={"Authorization": f"Bearer {CLAVE_A}"},
|
| 117 |
+
)
|
| 118 |
+
assert r.status_code == 200
|
| 119 |
+
|
| 120 |
+
_sesion(cliente, "b@example.com")
|
| 121 |
+
assert cliente.get("/api/lab/resultados", params={"muestra": "M-MENTIRA"}).status_code == 404
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_clave_sin_clinica_declarada_cae_en_la_de_por_defecto(cliente, monkeypatch):
|
| 125 |
+
"""Compatibilidad: un despliegue de una sola clínica no declara tenants y sigue funcionando."""
|
| 126 |
+
cfg = obtener_config()
|
| 127 |
+
monkeypatch.setattr(cfg, "lab_api_keys", ["clave-suelta"])
|
| 128 |
+
monkeypatch.setattr(cfg, "registro_abierto", True)
|
| 129 |
+
monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True)
|
| 130 |
+
|
| 131 |
+
assert _ingerir(cliente, "clave-suelta", "M-SUELTA").status_code == 200
|
| 132 |
+
_sesion(cliente, "suelto@example.com")
|
| 133 |
+
|
| 134 |
+
r = cliente.get("/api/lab/resultados", params={"muestra": "M-SUELTA"})
|
| 135 |
+
assert r.status_code == 200
|
| 136 |
+
assert r.json()["tenant"] == TENANT_POR_DEFECTO
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_clave_invalida_sigue_siendo_401(cliente, dos_clinicas):
|
| 140 |
+
assert _ingerir(cliente, "no-es-una-clave", "M-X").status_code == 401
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_el_tenant_de_la_clave_se_resuelve_bien():
|
| 144 |
+
"""Unidad sobre el parseo `tenant:clave`, que es lo que sostiene todo lo anterior."""
|
| 145 |
+
cfg = obtener_config().model_copy(
|
| 146 |
+
update={"lab_api_keys": ["clinica-a:secreta-a", "suelta-sin-tenant"]}
|
| 147 |
+
)
|
| 148 |
+
assert cfg.tenant_de_clave_dispositivo("secreta-a") == "clinica-a"
|
| 149 |
+
assert cfg.tenant_de_clave_dispositivo("suelta-sin-tenant") == TENANT_POR_DEFECTO
|
| 150 |
+
assert cfg.tenant_de_clave_dispositivo("clinica-a") is None # el prefijo no es la clave
|
| 151 |
+
assert cfg.tenant_de_clave_dispositivo("") is None
|