Spaces:
Sleeping
Sleeping
| """Aislamiento por cl铆nica de los resultados de analizador (ARCHITECTURE_REVIEW 搂2.1). | |
| El almac茅n era un dict global sin due帽o: cualquier sesi贸n autenticada pod铆a leer la muestra de | |
| cualquier cl铆nica, y `/api/lab/pendientes` enumeraba TODAS. Cada `muestra_id` abre en | |
| `/api/lab/resultados` el panel completo m谩s las pistas de paciente (nombre de la mascota, raza, | |
| sexo), as铆 que el aislamiento es lo 煤nico que separa a una cl铆nica de los datos de otra. | |
| Dos cosas que estas pruebas fijan y no pueden relajarse: el tenant lo pone el SERVIDOR (de la API | |
| key en la ingesta, de la cookie firmada en la lectura) y una muestra de otra cl铆nica se comporta | |
| como inexistente, no como prohibida. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from app.config import TENANT_POR_DEFECTO, obtener_config | |
| from app.lab.almacen import almacen | |
| from app.main import app | |
| CLAVE_A = "clave-de-la-clinica-a" | |
| CLAVE_B = "clave-de-la-clinica-b" | |
| PAYLOAD = { | |
| "muestra_id": "M-1", | |
| "instrumento_id": "vetscan-1", | |
| "fabricante": "Abaxis", | |
| "observaciones": [{"codigo_prueba": "GLU", "valor": "5.0", "unidad": "mmol/L"}], | |
| "momento": "2026-07-25T10:00:00Z", | |
| } | |
| def cliente(): | |
| almacen._datos.clear() | |
| with TestClient(app) as c: | |
| yield c | |
| almacen._datos.clear() | |
| def dos_clinicas(monkeypatch): | |
| """Dos dispositivos y dos cuentas, cada uno en su cl铆nica.""" | |
| cfg = obtener_config() | |
| monkeypatch.setattr(cfg, "lab_api_keys", [f"clinica-a:{CLAVE_A}", f"clinica-b:{CLAVE_B}"]) | |
| monkeypatch.setattr( | |
| cfg, "registro_allowlist", ["a@example.com=clinica-a", "b@example.com=clinica-b"] | |
| ) | |
| monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True) | |
| def _ingerir(cliente, clave, muestra): | |
| return cliente.post( | |
| "/api/lab/ingesta", | |
| json={**PAYLOAD, "muestra_id": muestra}, | |
| headers={"Authorization": f"Bearer {clave}"}, | |
| ) | |
| def _sesion(cliente, email): | |
| """Sesi贸n para ese email. La BD de pruebas se comparte, as铆 que si la cuenta ya existe de | |
| otra prueba se entra en vez de darla de alta (el tenant qued贸 fijado al crearla).""" | |
| r = cliente.post( | |
| "/api/auth/registro", | |
| json={"nombre": "V", "apellido": "Vet", "email": email, "password": "clave-segura-1"}, | |
| ) | |
| if r.status_code == 409: | |
| r = cliente.post( | |
| "/api/auth/login", json={"email": email, "password": "clave-segura-1"} | |
| ) | |
| assert r.status_code == 200, r.text | |
| return r | |
| def test_una_clinica_no_ve_la_muestra_de_otra(cliente, dos_clinicas): | |
| """El hallazgo central de 搂2.1: cualquier sesi贸n le铆a cualquier muestra.""" | |
| assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200 | |
| _sesion(cliente, "b@example.com") | |
| r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"}) | |
| # 404 y no 403: existir o no en otra cl铆nica tampoco es informaci贸n que se d茅. | |
| assert r.status_code == 404 | |
| def test_la_propia_clinica_si_la_ve(cliente, dos_clinicas): | |
| """El complemento obligatorio: aislar no puede romper el caso leg铆timo.""" | |
| assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200 | |
| _sesion(cliente, "a@example.com") | |
| r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"}) | |
| assert r.status_code == 200 | |
| assert r.json()["muestra_id"] == "M-DE-A" | |
| def test_pendientes_solo_enumera_lo_propio(cliente, dos_clinicas): | |
| """`pendientes` era el volcado en una petici贸n: ahora s贸lo lista la cl铆nica de la sesi贸n.""" | |
| _ingerir(cliente, CLAVE_A, "M-DE-A") | |
| _ingerir(cliente, CLAVE_B, "M-DE-B") | |
| _sesion(cliente, "b@example.com") | |
| r = cliente.get("/api/lab/pendientes") | |
| assert r.status_code == 200 | |
| ids = [x["muestra_id"] for x in r.json()] | |
| assert ids == ["M-DE-B"] | |
| def test_el_puente_no_puede_declarar_su_clinica(cliente, dos_clinicas): | |
| """El tenant sale de la API key; si viniera del cuerpo, mentir bastar铆a para escribir en otra.""" | |
| r = cliente.post( | |
| "/api/lab/ingesta", | |
| json={**PAYLOAD, "muestra_id": "M-MENTIRA", "tenant": "clinica-b"}, | |
| headers={"Authorization": f"Bearer {CLAVE_A}"}, | |
| ) | |
| assert r.status_code == 200 | |
| _sesion(cliente, "b@example.com") | |
| assert cliente.get("/api/lab/resultados", params={"muestra": "M-MENTIRA"}).status_code == 404 | |
| def test_clave_sin_clinica_declarada_cae_en_la_de_por_defecto(cliente, monkeypatch): | |
| """Compatibilidad: un despliegue de una sola cl铆nica no declara tenants y sigue funcionando.""" | |
| cfg = obtener_config() | |
| monkeypatch.setattr(cfg, "lab_api_keys", ["clave-suelta"]) | |
| monkeypatch.setattr(cfg, "registro_abierto", True) | |
| monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True) | |
| assert _ingerir(cliente, "clave-suelta", "M-SUELTA").status_code == 200 | |
| _sesion(cliente, "suelto@example.com") | |
| r = cliente.get("/api/lab/resultados", params={"muestra": "M-SUELTA"}) | |
| assert r.status_code == 200 | |
| assert r.json()["tenant"] == TENANT_POR_DEFECTO | |
| def test_clave_invalida_sigue_siendo_401(cliente, dos_clinicas): | |
| assert _ingerir(cliente, "no-es-una-clave", "M-X").status_code == 401 | |
| def test_el_tenant_de_la_clave_se_resuelve_bien(): | |
| """Unidad sobre el parseo `tenant:clave`, que es lo que sostiene todo lo anterior.""" | |
| cfg = obtener_config().model_copy( | |
| update={"lab_api_keys": ["clinica-a:secreta-a", "suelta-sin-tenant"]} | |
| ) | |
| assert cfg.tenant_de_clave_dispositivo("secreta-a") == "clinica-a" | |
| assert cfg.tenant_de_clave_dispositivo("suelta-sin-tenant") == TENANT_POR_DEFECTO | |
| assert cfg.tenant_de_clave_dispositivo("clinica-a") is None # el prefijo no es la clave | |
| assert cfg.tenant_de_clave_dispositivo("") is None | |