Files
CRM_AGENTES_CARGA/backend/tests/test_fin_csd.py
Jair Cedillo 6e208876f7 feat(fin): timbrado de CFDI 4.0 de ingreso con Comercio Digital
Cierra el ciclo de la factura: construcción del comprobante, sellado con el CSD de la
empresa emisora y transmisión al PAC.

- cfdi_builder: XML 4.0 de ingreso en el orden de atributos del XSD, del que depende la
  cadena original y con ella el sello. Todo el dinero con Decimal.
- sealer: cadena original vía el XSLT oficial del SAT y firma con la llave del CSD.
- pac_comercio_digital: cliente de timbrarV5. Conserva el código y el saldo de folios que
  el legado leía en una variable que descartaba (CFDI.cs:19324-19336).
- csd_service y core/crypto: CSD por empresa, con la contraseña cifrada en la base. Antes
  el certificado había que dejarlo a mano en el almacenamiento y su contraseña era una
  variable de entorno global, lo que no funciona con varias empresas emisoras.
- Cada intento —también los rechazados— guarda el XML que se transmitió y el que contestó
  el PAC: sin ese par no hay forma de reconstruir un rechazo cuando termina la petición.

La declaración XML se escribe a mano con comillas dobles. lxml la emite con comillas
simples, que es XML válido, pero Comercio Digital compara la cadena literal version="1.0"
y responde 642 "la versión del XML no es 1.0".

El modo (pruebas o producción) sale de invoices.stamping_mode y no se puede pasar por la
API: es lo único que separa un timbre de prueba de un CFDI con validez fiscal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:07:05 -05:00

142 lines
5.1 KiB
Python

"""Pruebas del cifrado de secretos y de la validación del CSD.
Nada sale a la red ni toca MinIO: la validación del par ``.cer``/``.key`` es criptografía
pura, y es justo la parte que importa comprobar.
"""
import datetime
import pytest
from cryptography import x509
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from fastapi import HTTPException
from api.v1.modules.fin.issuer import csd_service
from core import crypto
CERT_NUMBER = "00001000000700000001"
PASSWORD = "12345678a"
def _par(cert_number: str = CERT_NUMBER, password: str = PASSWORD):
"""Genera un CSD de juguete con el formato del SAT: (cer_der, key_der_cifrada)."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
nombre = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EMISOR DE PRUEBA")])
cert = (
x509.CertificateBuilder()
.subject_name(nombre)
.issuer_name(nombre)
.public_key(key.public_key())
.serial_number(int.from_bytes(cert_number.encode("ascii"), "big"))
.not_valid_before(datetime.datetime(2026, 1, 1))
.not_valid_after(datetime.datetime(2035, 1, 1))
.sign(key, hashes.SHA256())
)
return (
cert.public_bytes(serialization.Encoding.DER),
key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.PKCS8,
serialization.BestAvailableEncryption(password.encode()),
),
)
# ---------------------------------------------------------------------------------------
# Cifrado de secretos
# ---------------------------------------------------------------------------------------
@pytest.fixture
def clave_maestra(monkeypatch):
clave = Fernet.generate_key().decode()
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", clave, raising=False)
return clave
def test_cifrar_y_recuperar(clave_maestra):
token = crypto.encrypt_secret(PASSWORD)
assert token != PASSWORD, "el secreto no puede quedar en claro"
assert crypto.decrypt_secret(token) == PASSWORD
def test_dos_cifrados_del_mismo_valor_no_son_iguales(clave_maestra):
"""Fernet incluye IV y timestamp: dos tokens distintos para el mismo dato.
Importa porque si fueran iguales, comparar columnas revelaría qué empresas comparten
contraseña.
"""
assert crypto.encrypt_secret(PASSWORD) != crypto.encrypt_secret(PASSWORD)
def test_sin_clave_maestra_no_se_cifra(monkeypatch):
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "", raising=False)
assert crypto.secrets_available() is False
with pytest.raises(crypto.SecretsNotConfigured):
crypto.encrypt_secret(PASSWORD)
def test_clave_maestra_invalida_se_detecta(monkeypatch):
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "no-es-una-clave", raising=False)
with pytest.raises(crypto.SecretsNotConfigured):
crypto.encrypt_secret(PASSWORD)
def test_otra_clave_maestra_no_descifra(clave_maestra, monkeypatch):
"""Rotar la clave maestra deja ilegibles los secretos: tiene que decirlo, no romperse raro."""
token = crypto.encrypt_secret(PASSWORD)
monkeypatch.setattr(
crypto.settings, "CSD_ENCRYPTION_KEY", Fernet.generate_key().decode(), raising=False
)
with pytest.raises(crypto.SecretDecryptionError):
crypto.decrypt_secret(token)
def test_no_se_cifra_un_valor_vacio(clave_maestra):
with pytest.raises(ValueError):
crypto.encrypt_secret("")
# ---------------------------------------------------------------------------------------
# Validación del par .cer / .key
# ---------------------------------------------------------------------------------------
def test_par_correcto_devuelve_numero_de_certificado():
cer, key = _par()
assert csd_service._verify_pair(cer, key, PASSWORD) == CERT_NUMBER
def test_key_de_otro_certificado_se_rechaza():
"""El caso que motiva la validación: dos CSD mezclados.
Sin esto, el error aparecería hasta que el PAC rechace el comprobante, con un mensaje que
no menciona el certificado.
"""
cer, _ = _par()
_, key_ajena = _par(cert_number="00001000000700000002")
with pytest.raises(HTTPException) as exc:
csd_service._verify_pair(cer, key_ajena, PASSWORD)
assert exc.value.status_code == 422
assert "no corresponde" in str(exc.value.detail)
def test_contrasena_incorrecta_se_rechaza():
cer, key = _par()
with pytest.raises(HTTPException) as exc:
csd_service._verify_pair(cer, key, "incorrecta")
assert exc.value.status_code == 422
def test_certificado_que_no_es_x509_se_rechaza():
_, key = _par()
with pytest.raises(HTTPException) as exc:
csd_service._verify_pair(b"esto no es un certificado", key, PASSWORD)
assert exc.value.status_code == 422
def test_llave_que_no_es_una_llave_se_rechaza():
cer, _ = _par()
with pytest.raises(HTTPException) as exc:
csd_service._verify_pair(cer, b"esto no es una llave", PASSWORD)
assert exc.value.status_code == 422