El proxy de descarga del expediente comprobaba el status de EFC DENTRO del generador de streaming. Starlette manda la linea de estado en cuanto construye la StreamingResponse -antes de pedir el primer trozo-, asi que cuando el generador descubria el 404 la respuesta ya habia salido con 200: el usuario recibia un archivo vacio o cortado en vez del error. La traduccion asimetrica que fija el ticket -404 pasa como 404, todo lo demas como 502- estaba escrita pero nunca llegaba a ocurrir. Ahora la conexion se abre y su status se valida ANTES de construir la respuesta, con client.send(peticion, stream=True). El streaming se conserva intacto: lo unico que se adelanta es la cabecera de EFC, que por protocolo llega antes que el cuerpo. El cliente httpx se sigue creando fuera de todo `async with` y cerrandose en el finally, que era la parte que si estaba bien. De paso, un fallo de red contra EFC (timeout, conexion rechazada) daba un 500 opaco y dejaba el cliente httpx sin cerrar; ahora sale 502 y se cierra. Es el mismo camino de codigo que el ticket manda cubrir. Se agregan las pruebas del proxy que el ticket exigia y que no existian: streaming real -el cliente sigue vivo mientras se consumen los trozos y se cierra al terminar-, la pertenencia validada antes de tocar EFC, el organizacion_id del expediente en la peticion, que ninguna URL de almacenamiento se filtre al cuerpo ni a los headers, y la traduccion completa de errores. Todo contra httpx.MockTransport, sin red. stream_document y su ruta pasan a async por el cambio; la prueba del 409 en test_expediente_upload gana su await, sin el cual la corrutina no se ejecutaba y la prueba pasaba sin probar nada. Refs: T2026-08-046 (fase 7)
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Pruebas del proxy de descarga de documentos del expediente.
|
|
|
|
EFC nunca entrega una URL de MinIO —reescribir el host de una URL ya firmada invalida su SigV4— así
|
|
que el CRM tiene que hacer de segundo proxy. Lo que se fija aquí:
|
|
|
|
* La pertenencia se valida **antes** de tocar EFC, y el ``organizacion_id`` que se manda es el del
|
|
expediente, no uno que venga del cliente.
|
|
* La descarga es **streaming de verdad**: el cliente httpx sigue vivo mientras se consumen los
|
|
trozos y se cierra al terminar, pase lo que pase.
|
|
* La respuesta **no** filtra ninguna URL de almacenamiento, ni en el cuerpo ni en los headers.
|
|
* La traducción de errores es la asimétrica del ticket: un 404 de EFC sale 404, todo lo demás 502.
|
|
|
|
Todo va contra ``httpx.MockTransport``: **ninguna de estas pruebas toca la red.**
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
|
from api.v1.modules.crm.expedientes import routes as expedientes_routes
|
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
|
from api.v1.modules.crm.service_requests import service as sr_service
|
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
|
|
|
OTRO_TENANT = 99
|
|
ORGANIZACION = "11111111-2222-3333-4444-555555555555"
|
|
DOC_EFC = "9001"
|
|
PARTES = [b"%PDF-1.4 parte-1;", b"parte-2;", b"parte-3"]
|
|
ARCHIVO = b"".join(PARTES)
|
|
|
|
# Un header de almacenamiento que EFC podría llegar a mandar. Está aquí para que la prueba muerda:
|
|
# si alguien "simplificara" el proxy reenviando los headers del upstream tal cual, o devolviendo un
|
|
# redirect a la URL firmada, la fuga se vería en la respuesta del CRM.
|
|
URL_ALMACEN = "https://minio.efc.interno/bucket/doc.pdf?X-Amz-Signature=abc123"
|
|
|
|
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
|
|
|
|
|
class _ClienteEspia(httpx.AsyncClient):
|
|
"""``AsyncClient`` con el transporte simulado inyectado, que además anota si lo cerraron.
|
|
|
|
El proxy construye su cliente **dentro** del generador, así que la única forma de alcanzarlo
|
|
desde una prueba es sustituir la clase.
|
|
"""
|
|
|
|
transporte = None
|
|
instancias: list["_ClienteEspia"] = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs.pop("verify", None) # con transporte simulado no hay TLS que verificar
|
|
kwargs["transport"] = _ClienteEspia.transporte
|
|
super().__init__(*args, **kwargs)
|
|
self.cerrado = False
|
|
_ClienteEspia.instancias.append(self)
|
|
|
|
async def aclose(self):
|
|
self.cerrado = True
|
|
await super().aclose()
|
|
|
|
|
|
@pytest.fixture()
|
|
def entorno(db, monkeypatch):
|
|
"""Un expediente con un documento que **ya** vive en EFC, y EFC simulado."""
|
|
from core.config import settings
|
|
from core.efc_client import efc_client
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
|
monkeypatch.setattr(efc_client, "base_url", "https://efc.example.test", raising=False)
|
|
monkeypatch.setattr(efc_client, "api_key", "llave-de-prueba", raising=False)
|
|
|
|
subidos: dict = {}
|
|
monkeypatch.setattr(
|
|
expedientes_service, "put_object_bytes",
|
|
lambda key, body, content_type="application/octet-stream": subidos.update({key: body}),
|
|
)
|
|
|
|
solicitud = sr_service.create_service_request(
|
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
|
)
|
|
expediente = expedientes_service.find_by_service_request(
|
|
db, solicitud.id, TENANT_ID, COMPANY_ID
|
|
)
|
|
expediente.efc_organizacion_id = ORGANIZACION
|
|
|
|
documento = _documento_en_efc(db, expediente)
|
|
|
|
peticiones: list[httpx.Request] = []
|
|
_ClienteEspia.instancias = []
|
|
monkeypatch.setattr(httpx, "AsyncClient", _ClienteEspia)
|
|
|
|
def usar(handler):
|
|
def _registrar(request):
|
|
peticiones.append(request)
|
|
return handler(request)
|
|
|
|
_ClienteEspia.transporte = httpx.MockTransport(_registrar)
|
|
|
|
usar(_efc_entrega_el_archivo)
|
|
|
|
return {
|
|
"db": db,
|
|
"expediente": expediente,
|
|
"documento": documento,
|
|
"peticiones": peticiones,
|
|
"usar": usar,
|
|
}
|
|
|
|
|
|
def _documento_en_efc(db, expediente):
|
|
"""Documento ya replicado: el proxy solo entra en juego cuando hay ``efc_document_id``."""
|
|
from api.v1.modules.crm.documents.models import Document
|
|
|
|
documento = Document(
|
|
tenant_id=TENANT_ID,
|
|
company_id=COMPANY_ID,
|
|
expediente_id=expediente.id,
|
|
name="guia.pdf",
|
|
doc_type="MBL",
|
|
content_type="application/pdf",
|
|
efc_document_id=DOC_EFC,
|
|
efc_sync_state="SYNCED",
|
|
)
|
|
db.add(documento)
|
|
db.commit()
|
|
db.refresh(documento)
|
|
return documento
|
|
|
|
|
|
def _efc_entrega_el_archivo(request):
|
|
async def _por_partes():
|
|
for parte in PARTES:
|
|
yield parte
|
|
|
|
return httpx.Response(
|
|
200,
|
|
content=_por_partes(),
|
|
headers={
|
|
"Content-Type": "application/pdf",
|
|
"X-Storage-Url": URL_ALMACEN,
|
|
"Content-Disposition": 'attachment; filename="interno-de-efc.pdf"',
|
|
},
|
|
)
|
|
|
|
|
|
async def _descargar(entorno, **kwargs):
|
|
return await expedientes_service.stream_document(
|
|
entorno["db"],
|
|
kwargs.pop("expediente_id", entorno["expediente"].id),
|
|
kwargs.pop("document_id", entorno["documento"].id),
|
|
kwargs.pop("tenant_id", TENANT_ID),
|
|
kwargs.pop("company_id", COMPANY_ID),
|
|
)
|
|
|
|
|
|
async def _por_la_ruta(entorno, **kwargs):
|
|
return await expedientes_routes.download_expediente_document(
|
|
kwargs.pop("expediente_id", entorno["expediente"].id),
|
|
kwargs.pop("document_id", entorno["documento"].id),
|
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
|
current_user=kwargs.pop("current_user", USUARIO),
|
|
db=entorno["db"],
|
|
)
|
|
|
|
|
|
# ── Streaming ────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_el_proxy_entrega_los_bytes_tal_cual_llegan_de_efc(entorno):
|
|
iterador, content_type, filename = await _descargar(entorno)
|
|
|
|
assert b"".join([chunk async for chunk in iterador]) == ARCHIVO
|
|
assert content_type == "application/pdf"
|
|
assert filename == "guia.pdf"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_el_cliente_sigue_vivo_mientras_se_consumen_los_trozos_y_se_cierra_al_final(entorno):
|
|
"""**El defecto que el ``finally`` del proxy cubre.**
|
|
|
|
Si el cliente se creara en un ``async with`` de fuera del generador, ese bloque lo cerraría en
|
|
cuanto la función devuelve —FastAPI pide los trozos *después*— y la descarga moriría a medias
|
|
sin error visible. Aquí se comprueba lo contrario: abierto durante, cerrado después.
|
|
"""
|
|
iterador, _, _ = await _descargar(entorno)
|
|
generador = iterador.__aiter__()
|
|
|
|
primero = await generador.__anext__()
|
|
assert primero
|
|
cliente = _ClienteEspia.instancias[-1]
|
|
assert cliente.cerrado is False, "el cliente se cerró antes de terminar de leer el archivo"
|
|
|
|
async for _ in generador:
|
|
pass
|
|
assert cliente.cerrado is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_el_cliente_se_cierra_aunque_efc_conteste_error(entorno):
|
|
entorno["usar"](lambda request: httpx.Response(500, text="boom"))
|
|
|
|
with pytest.raises(HTTPException):
|
|
await _descargar(entorno)
|
|
|
|
assert _ClienteEspia.instancias[-1].cerrado is True
|
|
|
|
|
|
# ── Las dos guardas de pertenencia ───────────────────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_la_pertenencia_se_valida_ANTES_de_tocar_efc(entorno):
|
|
"""Sin esto, acertar un ``document_id`` bastaría para leer el expediente de otro cliente — y
|
|
peor: el ``organizacion_id`` se deriva del expediente, así que el proxy iría a preguntarle a la
|
|
organización ajena."""
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _descargar(entorno, tenant_id=OTRO_TENANT)
|
|
|
|
assert exc.value.status_code == 404
|
|
assert entorno["peticiones"] == [], "se llamó a EFC antes de validar la pertenencia"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_un_documento_de_otra_company_no_se_descarga(entorno):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _descargar(entorno, company_id=777)
|
|
|
|
assert exc.value.status_code == 404
|
|
assert entorno["peticiones"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_el_proxy_manda_el_organizacion_id_del_expediente(entorno):
|
|
iterador, _, _ = await _descargar(entorno)
|
|
async for _ in iterador:
|
|
pass
|
|
|
|
peticion = entorno["peticiones"][0]
|
|
assert peticion.url.params["organizacion_id"] == ORGANIZACION
|
|
assert DOC_EFC in str(peticion.url.path)
|
|
assert peticion.headers["X-Api-Key"] == "llave-de-prueba"
|
|
|
|
|
|
# ── Nada de URLs de almacenamiento hacia afuera ──────────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_la_respuesta_no_expone_ninguna_url_de_almacenamiento(entorno):
|
|
"""El proxy existe justamente para no entregar la URL firmada. Ni redirect, ni header
|
|
reenviado, ni URL en el cuerpo."""
|
|
respuesta = await _por_la_ruta(entorno)
|
|
|
|
assert respuesta.status_code == 200
|
|
cabeceras = {k.lower(): v for k, v in respuesta.headers.items()}
|
|
assert "location" not in cabeceras
|
|
assert not any("x-amz" in v.lower() or "minio" in v.lower() for v in cabeceras.values())
|
|
assert "x-storage-url" not in cabeceras
|
|
|
|
cuerpo = b"".join([chunk async for chunk in respuesta.body_iterator])
|
|
assert cuerpo == ARCHIVO
|
|
assert b"X-Amz-Signature" not in cuerpo
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_el_nombre_que_ve_el_usuario_es_el_del_crm_no_el_de_efc(entorno):
|
|
respuesta = await _por_la_ruta(entorno)
|
|
assert 'filename="guia.pdf"' in respuesta.headers["content-disposition"]
|
|
|
|
|
|
# ── Traducción de errores: 404 pasa, el resto es 502 ─────────────────────────
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_un_404_de_efc_llega_al_usuario_como_404(entorno):
|
|
"""Y **llega**: la traducción tiene que ocurrir antes de que la respuesta empiece a salir, o el
|
|
navegador recibiría un 200 con el cuerpo cortado."""
|
|
entorno["usar"](lambda request: httpx.Response(404, json={"detail": "no está"}))
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _por_la_ruta(entorno)
|
|
|
|
assert exc.value.status_code == 404
|
|
assert exc.value.detail == "No se pudo obtener el archivo del expediente electrónico."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("codigo", [400, 403, 500, 503])
|
|
async def test_cualquier_otro_error_de_efc_sale_como_502(entorno, codigo):
|
|
entorno["usar"](lambda request: httpx.Response(codigo, text="boom"))
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _por_la_ruta(entorno)
|
|
|
|
assert exc.value.status_code == 502
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_un_timeout_contra_efc_sale_como_502_y_no_como_500(entorno):
|
|
"""Un fallo de red es de la integración, no del usuario: 502. Dejarlo escapar sería un 500
|
|
opaco y un cliente httpx sin cerrar."""
|
|
def _revienta(request):
|
|
raise httpx.ConnectTimeout("EFC no responde")
|
|
|
|
entorno["usar"](_revienta)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _por_la_ruta(entorno)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert _ClienteEspia.instancias[-1].cerrado is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sin_integracion_configurada_la_ruta_responde_502(entorno, monkeypatch):
|
|
from core.efc_client import efc_client
|
|
|
|
monkeypatch.setattr(efc_client, "base_url", "", raising=False)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _por_la_ruta(entorno)
|
|
|
|
assert exc.value.status_code == 502
|
|
assert entorno["peticiones"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_un_documento_de_otro_tenant_por_la_ruta_da_404(entorno):
|
|
with pytest.raises(HTTPException) as exc:
|
|
await _por_la_ruta(entorno, current_user={"tenant_id": OTRO_TENANT, "sub": "user-2"})
|
|
|
|
assert exc.value.status_code == 404
|