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>
142 lines
5.1 KiB
Python
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
|