Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
|