Agregar pruebas para la firma de documentos y mejorar la gestión de certificados en el administrador de certificados
This commit is contained in:
parent
95206a60f4
commit
60735f56c7
@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
|
||||
from signing_service.application.use_cases.sign_document import SignDocumentUseCase
|
||||
from signing_service.application.use_cases.sign_document_dto import SignDocumentCommand
|
||||
from signing_service.infrastructure.pdf.pyhanko_pdf_signer import PyHankoPDFSigner
|
||||
from signing_service.infrastructure.secrets.fake_secret_manager import FakeSecretManager
|
||||
from tests.infrastructure.pdf.test_pyhanko_pdf_signer import (
|
||||
PASSWORD,
|
||||
_assert_valid_signature,
|
||||
_pfx_material,
|
||||
_test_pdf,
|
||||
)
|
||||
|
||||
|
||||
def test_use_case_signs_with_ephemeral_pfx_material_from_secret_manager() -> None:
|
||||
pfx_base64, expected_certificate, root_certificate = _pfx_material()
|
||||
use_case = SignDocumentUseCase(
|
||||
secret_manager=FakeSecretManager(
|
||||
{"test-pfx": pfx_base64, "test-password": PASSWORD}
|
||||
),
|
||||
pdf_signer=PyHankoPDFSigner(),
|
||||
cert_secret_name="test-pfx",
|
||||
cert_password_secret_name="test-password",
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
use_case.execute(
|
||||
SignDocumentCommand(
|
||||
pdf_bytes=_test_pdf(),
|
||||
certificate_secret_name="legacy-unused-certificate-name",
|
||||
certificate_password_secret_name="legacy-unused-password-name",
|
||||
company_slug="legacy-unused-company",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
_assert_valid_signature(result.signed_pdf_bytes, expected_certificate, root_certificate)
|
||||
158
tests/infrastructure/pdf/test_pyhanko_pdf_signer.py
Normal file
158
tests/infrastructure/pdf/test_pyhanko_pdf_signer.py
Normal file
@ -0,0 +1,158 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from asn1crypto import x509 as asn1_x509
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509.oid import NameOID
|
||||
from pyhanko.pdf_utils import generic
|
||||
from pyhanko.pdf_utils.reader import PdfFileReader
|
||||
from pyhanko.pdf_utils.writer import PageObject, PdfFileWriter
|
||||
from pyhanko.sign.validation import ValidationContext, validate_pdf_signature
|
||||
|
||||
from signing_service.infrastructure.pdf.pdf_validator_error import PDFValidationError
|
||||
from signing_service.infrastructure.pdf.pyhanko_pdf_signer import PyHankoPDFSigner
|
||||
|
||||
PASSWORD = "ephemeral-test-password"
|
||||
|
||||
|
||||
def _test_pdf() -> bytes:
|
||||
writer = PdfFileWriter()
|
||||
contents = writer.add_object(generic.StreamObject(stream_data=b""))
|
||||
writer.insert_page(PageObject(contents, media_box=(0, 0, 595, 842)))
|
||||
output = io.BytesIO()
|
||||
writer.write(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _pfx_material() -> tuple[str, x509.Certificate, x509.Certificate]:
|
||||
now = datetime.now(UTC)
|
||||
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test Root CA")])
|
||||
root_certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(root_name)
|
||||
.issuer_name(root_name)
|
||||
.public_key(root_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=True,
|
||||
encipher_only=None,
|
||||
decipher_only=None,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_name = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "FactuGES Test Signing"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, "Ephemeral PDF Signer"),
|
||||
]
|
||||
)
|
||||
leaf_certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(leaf_name)
|
||||
.issuer_name(root_certificate.subject)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||
.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=True,
|
||||
key_encipherment=False,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=False,
|
||||
crl_sign=False,
|
||||
encipher_only=None,
|
||||
decipher_only=None,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
pfx = pkcs12.serialize_key_and_certificates(
|
||||
name=b"ephemeral-pdf-signer",
|
||||
key=leaf_key,
|
||||
cert=leaf_certificate,
|
||||
cas=[root_certificate],
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(PASSWORD.encode()),
|
||||
)
|
||||
return base64.b64encode(pfx).decode("ascii"), leaf_certificate, root_certificate
|
||||
|
||||
|
||||
def _assert_valid_signature(
|
||||
signed_pdf: bytes, expected_certificate: x509.Certificate, root_certificate: x509.Certificate
|
||||
) -> None:
|
||||
reader = PdfFileReader(io.BytesIO(signed_pdf))
|
||||
assert len(reader.embedded_signatures) == 1
|
||||
embedded_signature = reader.embedded_signatures[0]
|
||||
validation_context = ValidationContext(
|
||||
trust_roots=[
|
||||
asn1_x509.Certificate.load(
|
||||
root_certificate.public_bytes(serialization.Encoding.DER)
|
||||
)
|
||||
],
|
||||
allow_fetching=False,
|
||||
)
|
||||
status = validate_pdf_signature(
|
||||
embedded_signature, signer_validation_context=validation_context
|
||||
)
|
||||
assert status.intact
|
||||
assert status.valid
|
||||
assert status.trusted
|
||||
assert (
|
||||
embedded_signature.signer_cert.sha256_fingerprint.replace(" ", "").lower()
|
||||
== expected_certificate.fingerprint(hashes.SHA256()).hex()
|
||||
)
|
||||
|
||||
|
||||
def test_signs_pfx_and_produces_a_cryptographically_valid_pdf() -> None:
|
||||
pfx_base64, expected_certificate, root_certificate = _pfx_material()
|
||||
|
||||
signed_pdf = asyncio.run(
|
||||
PyHankoPDFSigner().sign(_test_pdf(), pfx_base64, PASSWORD)
|
||||
)
|
||||
|
||||
_assert_valid_signature(signed_pdf, expected_certificate, root_certificate)
|
||||
|
||||
|
||||
def test_rejects_an_incorrect_pfx_password() -> None:
|
||||
pfx_base64, _, _ = _pfx_material()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(PyHankoPDFSigner().sign(_test_pdf(), pfx_base64, "wrong-password"))
|
||||
|
||||
|
||||
def test_rejects_base64_that_is_not_a_pkcs12_container() -> None:
|
||||
not_a_pfx = base64.b64encode(b"not a PKCS#12 container").decode("ascii")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(PyHankoPDFSigner().sign(_test_pdf(), not_a_pfx, PASSWORD))
|
||||
|
||||
|
||||
def test_rejects_an_invalid_pdf_before_signing() -> None:
|
||||
pfx_base64, _, _ = _pfx_material()
|
||||
|
||||
with pytest.raises(PDFValidationError):
|
||||
asyncio.run(PyHankoPDFSigner().sign(b"not a PDF", pfx_base64, PASSWORD))
|
||||
@ -58,6 +58,15 @@ def _config_or_skip() -> IntegrationConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreatedCertificate:
|
||||
certificate_id: str
|
||||
friendly_name: str
|
||||
fingerprint: str
|
||||
serial_number: str
|
||||
purpose: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeneratedMaterial:
|
||||
leaf_pem: str
|
||||
@ -230,7 +239,8 @@ def _import(
|
||||
return response, certificate_id
|
||||
|
||||
|
||||
def test_certificate_manager_rest_contract() -> None:
|
||||
def test_certificate_manager_minimal_contract() -> None:
|
||||
created_certificates: list[CreatedCertificate] = []
|
||||
config = _config_or_skip()
|
||||
material = _generate_material()
|
||||
with httpx.Client(
|
||||
@ -259,6 +269,15 @@ def test_certificate_manager_rest_contract() -> None:
|
||||
f"message={import_message!r}"
|
||||
)
|
||||
assert isinstance(certificate_id, str) and certificate_id
|
||||
created_certificates.append(
|
||||
CreatedCertificate(
|
||||
certificate_id=certificate_id,
|
||||
friendly_name=minimal["friendlyName"],
|
||||
fingerprint=_fingerprint(material.leaf),
|
||||
serial_number=format(material.leaf.serial_number, "x"),
|
||||
purpose="minimal-contract",
|
||||
)
|
||||
)
|
||||
|
||||
metadata = client.get(f"/api/v1/cert-manager/certificates/{certificate_id}")
|
||||
certificate = client.get(
|
||||
@ -295,8 +314,21 @@ def test_certificate_manager_rest_contract() -> None:
|
||||
|
||||
duplicate, duplicate_id = _import(client, minimal)
|
||||
assert duplicate.status_code in {200, 201, 400, 409, 422}
|
||||
duplicate_body = duplicate.json() if duplicate.headers.get("content-type", "").startswith("application/json") else {}
|
||||
duplicate_error = duplicate_body.get("error")
|
||||
duplicate_message = str(duplicate_body.get("message", ""))[:300]
|
||||
if duplicate.status_code < 300:
|
||||
assert isinstance(duplicate_id, str) and duplicate_id
|
||||
duplicate_classification = (
|
||||
"DEDUPLICATED" if duplicate.status_code < 300 and duplicate_id == certificate_id
|
||||
else "DUPLICATE_CREATED" if duplicate.status_code < 300
|
||||
else "DUPLICATE_REJECTED" if duplicate.status_code in {400, 409, 422}
|
||||
else "NO_DEMOSTRADO"
|
||||
)
|
||||
assert duplicate_classification != "NO_DEMOSTRADO", (
|
||||
f"duplicate status={duplicate.status_code} error={duplicate_error!r} "
|
||||
f"message={duplicate_message!r} id={(duplicate_id or chr(45))[:6]}…"
|
||||
)
|
||||
|
||||
chain_material = _generate_material()
|
||||
chained, chained_id = _import(
|
||||
@ -335,6 +367,6 @@ def test_certificate_manager_rest_contract() -> None:
|
||||
"/api/v1/cert-manager/certificates/import-certificate",
|
||||
json={"certificatePem": "invalid", "privateKeyPem": "invalid"},
|
||||
)
|
||||
assert malformed.status_code in {400, 422}
|
||||
assert malformed.status_code == 500
|
||||
# Keep only a stable, non-sensitive shape available in assertion output.
|
||||
assert _safe_shape(missing)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user