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)
86 lines
3.1 KiB
Python
86 lines
3.1 KiB
Python
"""Alcance de ``GET /uploads/url``: qué objetos puede firmar este endpoint y cuáles no.
|
|
|
|
**Cierra una fuga real.** Antes bastaba con que la key empezara por
|
|
``tenants/{tid}/companies/{cid}/`` para firmar una URL de lectura, lo que permitía firmar
|
|
**cualquier** objeto de esa company —incluidos los certificados de la FIEL— con solo el permiso de
|
|
módulo ``crm.access``. El alcance de este endpoint es «los archivos que el CRM subió», no «todo el
|
|
almacén de la company».
|
|
"""
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension
|
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
|
|
|
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
|
PREFIJO = f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _sin_s3(monkeypatch):
|
|
import api.v1.modules.crm.uploads.routes as uploads
|
|
|
|
monkeypatch.setattr(uploads, "presigned_get_url", lambda key, **k: f"https://firmada/{key}")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sufijo",
|
|
[
|
|
"certificates/fiel_20260101.key", # llave privada de la FIEL
|
|
"certificates/fiel_20260101.cer",
|
|
"invoices/9/cove/cove.xml",
|
|
"imports/csv/invoice/job-1.csv",
|
|
"branding/logo.png",
|
|
"doda/1/report/doda_report.pdf",
|
|
"signatures/1/photo_x.png",
|
|
],
|
|
)
|
|
def test_no_se_puede_firmar_nada_fuera_de_los_documentos_del_crm(sufijo):
|
|
with pytest.raises(HTTPException) as exc:
|
|
get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"sufijo",
|
|
[
|
|
"crm-docs/abc123/contrato.pdf",
|
|
"expedientes/1/documents/abc123_guia.pdf",
|
|
],
|
|
)
|
|
def test_los_documentos_del_crm_si_se_pueden_firmar(sufijo):
|
|
resp = get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
|
assert resp["url"].endswith(sufijo)
|
|
|
|
|
|
def test_no_se_puede_firmar_nada_de_otro_tenant_ni_de_otra_company():
|
|
"""El aislamiento previo sigue en pie: es una guarda adicional, no un reemplazo."""
|
|
for key in (
|
|
"tenants/999/companies/1/crm-docs/a/b.pdf",
|
|
f"tenants/{TENANT_ID}/companies/999/crm-docs/a/b.pdf",
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
get_upload_url(key, COMPANY_ID, USUARIO)
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_una_key_que_solo_CONTIENE_el_prefijo_no_pasa():
|
|
"""La comprobación es de prefijo, no de subcadena: ``startswith`` y no ``in``."""
|
|
with pytest.raises(HTTPException) as exc:
|
|
get_upload_url(f"otro/{PREFIJO}crm-docs/a/b.pdf", COMPANY_ID, USUARIO)
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable():
|
|
for nombre in ("virus.exe", "script.sh", "macro.bat", "lib.dll", "sin_extension"):
|
|
with pytest.raises(HTTPException) as exc:
|
|
validar_extension(nombre)
|
|
assert exc.value.status_code == 422
|
|
assert exc.value.detail == "Ese tipo de archivo no está permitido."
|
|
|
|
|
|
def test_la_allowlist_acepta_los_formatos_de_documento():
|
|
for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"):
|
|
validar_extension(nombre) # no lanza
|