Files
CRM_AGENTES_CARGA/backend/tests/test_expediente_upload.py
marcos d97cdd2f73 feat(crm,ops): subida de un paso al expediente, proxy de descarga y UI del estado
Cierra el carril de cara al usuario: adjunta un documento desde la ficha del
embarque y lo ve aparecer con badge "En expediente". Si EFC esta caido lo ve
como "Pendiente de enviar" en vez de perder su trabajo, y el sistema lo entrega
solo cuando EFC vuelve, sin duplicarlo.

La subida es de UN paso -guarda, registra y encola- y responde 201 PASE LO QUE
PASE con EFC. La alternativa era llamar a EFC dentro del request, y ahi un EFC
caido devuelve un error al usuario con el archivo ya subido a medias.

La descarga es un proxy con streaming, no una redireccion: EFC nunca entrega una
URL de MinIO, y reescribir el host de una URL ya firmada invalida su SigV4. El
AsyncClient se crea DENTRO del generador y se cierra en finally; creado fuera,
en un `async with`, se cerraria antes de que empiece el streaming -FastAPI
consume el generador despues de devolver la respuesta- y la descarga moriria a
la mitad. Dos guardas: la pertenencia se valida ANTES de tocar EFC, y se manda
el organizacion_id del expediente para que la verificacion de EFC tambien
dispare. Sin la primera, un document_id que coincidiera leeria el expediente de
otro tenant, y el organizacion_id se deriva del expediente, asi que el CRM iria
a preguntarle a la organizacion de otro cliente.

TRES DEFECTOS PREEXISTENTES QUE ESTA FASE CORRIGE DE PASO:

1. `POST /uploads` hacia `await file.read()` COMPLETO antes de validar el
   tamano: un archivo de 2 GB se bufferizaba entero en RAM del proceso solo para
   responder 422 despues. Ahora la lectura es por partes y aborta al pasar del
   tope.
2. No habia allowlist de extension, a diferencia del avatar y el centro de
   ayuda, que si la tienen. La de aqui coincide con la de EFC: aceptar algo que
   alla se rechaza guardaria el archivo y condenaria su entrega a `failed`.
3. `GET /uploads/url` validaba SOLO que la key empezara con
   tenants/{tid}/companies/{cid}/, lo que permitia firmar una URL de lectura
   para CUALQUIER objeto de esa company -incluidos los certificados de la FIEL
   bajo certificates/- con solo el permiso de modulo crm.access. Ahora exige
   ademas uno de los dos subarboles de documentos del CRM. Hay test por cada
   prefijo sensible.

El borrado DESASOCIA, no destruye en EFC: el gateway de Anexo22 nunca llama al
DELETE de EFC -verificado, ese metodo no existe en su cliente- y record.Document
alla no tiene vigencia ni purga, asi que la politica implicita es conservar. Un
documento que manana puede ser parte del expediente de un pedimento real es
riesgo de retencion fiscal.

En el frontend, abrir un documento del expediente NO usa window.open sobre la
URL del proxy: esa llamada no lleva el header Authorization y el backend
responderia 401. Va por api.getBlob y URL.createObjectURL. La descarga se
ramifica en tres y el ORDEN importa: efc_document_id primero, porque al
confirmar la entrega `delete_local` borra la copia local y file_key queda en
NULL. `postFormData` solo publica `fetchApiFormDataPost`, que ya existia con la
maquinaria de progreso pero sin exportar, asi que nadie podia usarlo.

Verificacion:
  backend  pytest tests/            EXIT=0  178 passed 1 skipped (baseline 70)
  frontend pnpm run i18n:compile    EXIT=0
           pnpm run check           EXIT=1  38 errores/17 warnings en 20 archivos
                                            IDENTICO al baseline: 55 hallazgos
                                            antes y despues, cero agregados. La
                                            unica diferencia es que el hallazgo
                                            preexistente de RelatedManager se
                                            movio de la linea 251 a la 317 al
                                            insertar codigo arriba.
           pnpm run test:unit       EXIT=1  2 failed 43 passed (baseline 2/38);
                                            los 2 rojos son los preexistentes de
                                            backend.test.ts, que pide un backend
                                            vivo en el host `backend` de docker
           pnpm run build           EXIT=0

El verde se vio fallar primero: reintroduciendo la fuga de `/uploads/url` los
tests de certificates/, doda/ y signatures/ se pusieron rojos; volviendo al
read() completo cayo el test que afirma que el tope corta sin leer el archivo
entero; y agregando un tipo de documento solo en el frontend, el test de paridad
lo nombro: "expected [ 'carta_poder' ] to deeply equal []".

Ticket: T2026-08-046 (fase 7 de 7)
2026-08-07 18:25:02 -06:00

267 lines
10 KiB
Python

"""Pruebas de la subida de un paso al expediente.
Lo que se fija: que el tope de tamaño se aplique **antes** de consumir el cuerpo entero, que el
catálogo y la allowlist se validen antes de tocar el almacén, y que **un EFC caído siga devolviendo
201** con el documento en «Pendiente de enviar».
"""
import io
import pytest
from fastapi import HTTPException, UploadFile
from api.v1.modules.crm.expediente_gateway import service as gateway
from api.v1.modules.crm.expediente_gateway.models import EfcFileOutbox
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 api.v1.modules.crm.uploads.routes import MAX_UPLOAD_BYTES
from tests.conftest import COMPANY_ID, TENANT_ID
OTRO_TENANT = 99
class _ArchivoContado(io.BytesIO):
"""``BytesIO`` que cuenta cuántos bytes se le han leído.
Es lo que permite afirmar que el tope se aplicó **sin** haber leído el archivo completo: un
``await file.read()`` a secas lo bufferizaría entero antes de poder rechazarlo.
"""
def __init__(self, data: bytes):
super().__init__(data)
self.leidos = 0
def read(self, size=-1):
chunk = super().read(size)
self.leidos += len(chunk)
return chunk
def _upload(nombre: str, contenido: bytes, content_type: str = "application/pdf") -> UploadFile:
return UploadFile(filename=nombre, file=_ArchivoContado(contenido), headers=None)
@pytest.fixture()
def entorno(db, monkeypatch):
from core.config import settings
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"))
subidos = {}
import api.v1.modules.crm.expedientes.service as exp_service
monkeypatch.setattr(
exp_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)
return {"db": db, "expediente": expediente, "subidos": subidos}
async def _adjuntar(entorno, **kwargs):
return await expedientes_service.attach_document(
entorno["db"],
kwargs.pop("expediente_id", entorno["expediente"].id),
kwargs.pop("file", _upload("guia.pdf", b"%PDF-1.4 contenido")),
kwargs.pop("doc_type", "MBL"),
kwargs.pop("tenant_id", TENANT_ID),
kwargs.pop("company_id", COMPANY_ID),
kwargs.pop("name", None),
kwargs.pop("user_id", "user-1"),
)
# ── Camino feliz ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_la_subida_crea_el_documento_pendiente_y_su_fila_de_outbox(entorno):
documento = await _adjuntar(entorno)
assert documento.id is not None
assert documento.efc_sync_state == "PENDING"
assert documento.expediente_id == entorno["expediente"].id
assert documento.efc_document_ref == f"CRMDOC-{COMPANY_ID}-{documento.id}"
assert documento.content_sha256 is not None
filas = entorno["db"].query(EfcFileOutbox).filter(
EfcFileOutbox.source_id == documento.id
).all()
assert len(filas) == 1
assert filas[0].delete_local is True
assert filas[0].crm_document_ref == documento.efc_document_ref
@pytest.mark.asyncio
async def test_el_objeto_se_guarda_bajo_el_arbol_del_expediente(entorno):
documento = await _adjuntar(entorno)
keys = list(entorno["subidos"])
assert len(keys) == 1
assert keys[0] == documento.file_key
assert f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/expedientes/{entorno['expediente'].id}/" in keys[0]
# ── Validaciones ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_un_doc_type_fuera_del_catalogo_da_422_y_no_sube_nada(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, doc_type="tipo_inventado")
assert exc.value.status_code == 422
assert exc.value.detail == "Ese tipo de documento no está en el catálogo."
assert entorno["subidos"] == {} # nada llegó al almacén
@pytest.mark.asyncio
async def test_una_extension_no_permitida_da_422_y_no_sube_nada(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, file=_upload("malicioso.exe", b"MZ"))
assert exc.value.status_code == 422
assert exc.value.detail == "Ese tipo de archivo no está permitido."
assert entorno["subidos"] == {}
@pytest.mark.asyncio
async def test_la_extension_se_valida_ANTES_de_leer_el_archivo(entorno):
"""Rechazar por extensión no debe costar leer el archivo: es gratis saberlo por el nombre."""
archivo = _upload("malicioso.exe", b"M" * (2 * 1024 * 1024))
with pytest.raises(HTTPException):
await _adjuntar(entorno, file=archivo)
assert archivo.file.leidos == 0
@pytest.mark.asyncio
async def test_un_archivo_por_encima_del_tope_da_422_SIN_haberlo_leido_completo(entorno):
"""**El defecto que esta fase corrige.** Con ``await file.read()`` a secas, un archivo de 2 GB
se bufferizaba entero en RAM solo para responder 422 después.
Aquí la lectura es por partes y aborta al pasar del tope, así que lo leído se queda cerca del
tope y no llega al tamaño total.
"""
tamano = MAX_UPLOAD_BYTES + (3 * 1024 * 1024)
archivo = _upload("enorme.pdf", b"x" * tamano)
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, file=archivo)
assert exc.value.status_code == 422
assert exc.value.detail == "El archivo excede el tamaño máximo permitido."
assert archivo.file.leidos < tamano
assert entorno["subidos"] == {}
@pytest.mark.asyncio
async def test_un_expediente_de_otro_tenant_da_404(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, tenant_id=OTRO_TENANT)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_un_expediente_inexistente_da_404(entorno):
with pytest.raises(HTTPException) as exc:
await _adjuntar(entorno, expediente_id=999999)
assert exc.value.status_code == 404
# ── EFC caído ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_con_efc_apagado_la_subida_SIGUE_funcionando(entorno, monkeypatch):
"""El caso que justifica todo el carril: el usuario no pierde su trabajo porque EFC no esté.
Sin outbox habría que llamar a EFC dentro del request, y un EFC caído devolvería un error al
usuario con el archivo ya subido a medias.
"""
from core.config import settings
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
documento = await _adjuntar(entorno)
assert documento.id is not None
assert documento.efc_sync_state == "PENDING"
assert documento.file_key is not None # la copia local es lo único que hay
# Sin integración no se encola: el barrido de huecos lo recogerá cuando se encienda.
assert entorno["db"].query(EfcFileOutbox).filter(
EfcFileOutbox.source_id == documento.id
).count() == 0
@pytest.mark.asyncio
async def test_si_el_despacho_al_broker_falla_la_subida_igual_responde(entorno, monkeypatch):
"""«Si el broker no responde, el sweep la recoge»: el despacho es best-effort."""
def _revienta(*a, **k):
raise RuntimeError("Valkey no responde")
monkeypatch.setattr(gateway, "_dispatch_file_delivery", _revienta)
with pytest.raises(RuntimeError):
await _adjuntar(entorno)
# La fila SÍ quedó encolada antes del despacho: el barrido la va a recoger.
assert entorno["db"].query(EfcFileOutbox).count() == 1
# ── Lectura y baja ───────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_el_listado_solo_devuelve_los_documentos_de_ese_expediente(entorno):
documento = await _adjuntar(entorno)
docs = expedientes_service.list_expediente_documents(
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
)
assert [d.id for d in docs] == [documento.id]
@pytest.mark.asyncio
async def test_un_documento_de_otro_tenant_no_se_puede_leer(entorno):
documento = await _adjuntar(entorno)
with pytest.raises(HTTPException) as exc:
expedientes_service.get_expediente_document(
entorno["db"], entorno["expediente"].id, documento.id, OTRO_TENANT, COMPANY_ID
)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_la_baja_desasocia_y_no_borra_nada_en_efc(entorno):
documento = await _adjuntar(entorno)
expedientes_service.detach_document(
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
)
assert documento.deleted_at is not None
assert expedientes_service.list_expediente_documents(
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
) == []
# La fila del outbox NO se toca: si ya se entregó, en EFC sigue estando.
assert entorno["db"].query(EfcFileOutbox).count() == 1
@pytest.mark.asyncio
async def test_descargar_un_documento_que_aun_no_llego_a_efc_da_409(entorno):
"""No es un 404: el documento existe, solo que todavía no está del otro lado."""
documento = await _adjuntar(entorno)
with pytest.raises(HTTPException) as exc:
expedientes_service.stream_document(
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
)
assert exc.value.status_code == 409