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)
267 lines
10 KiB
Python
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
|