diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index da6dd841..04f85830 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -99,6 +99,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfRestrictPayload, + PdfSignPayload, PdfSplitPayload, PdfToExcelPayload, PdfToPdfaPayload, @@ -131,6 +132,8 @@ PdfRedactionInstruction, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, PdfXType, PngColorModel, SummaryFormat, @@ -3070,6 +3073,42 @@ def add_attachment_to_pdf( timeout=timeout, ) + def sign_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + signature_configuration: PdfSignatureConfiguration, + credentials: PdfSignatureCredentials, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Digitally sign a PDF using PFX credentials or a certificate/private key.""" + + payload: dict[str, Any] = { + "files": file, + "signature_configuration": signature_configuration, + } + payload.update(credentials) + + if logo is not None: + payload["logo"] = logo + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/signed-pdf", + payload=payload, + payload_model=PdfSignPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -4450,6 +4489,42 @@ async def add_attachment_to_pdf( timeout=timeout, ) + async def sign_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + signature_configuration: PdfSignatureConfiguration, + credentials: PdfSignatureCredentials, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Digitally sign a PDF using PFX credentials or a certificate/private key.""" + + payload: dict[str, Any] = { + "files": file, + "signature_configuration": signature_configuration, + } + payload.update(credentials) + + if logo is not None: + payload["logo"] = logo + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/signed-pdf", + payload=payload, + payload_model=PdfSignPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 997f3937..ac467393 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -170,6 +170,13 @@ def _serialize_text_objects(value: list[BaseModel]) -> str: return to_json(payload).decode() +def _serialize_signature_configuration( + value: _PdfSignatureConfigurationModel, +) -> str: + payload = value.model_dump(mode="json", exclude_none=True) + return to_json(payload).decode() + + def _allowed_mime_types( allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None ) -> Callable[[Any], Any]: @@ -603,6 +610,41 @@ class PdfPresetRedactionModel(BaseModel): value: PdfRedactionPreset +class _PdfSignaturePointModel(BaseModel): + x: str | int | float + y: str | int | float + + +class _PdfSignatureLocationModel(BaseModel): + bottom_left: _PdfSignaturePointModel + top_right: _PdfSignaturePointModel + page: str | int + + +class _PdfSignatureDisplayModel(BaseModel): + include_distinguished_name: bool | None = None + include_datetime: bool | None = None + contact: str | None = None + location: str | None = None + name: str | None = None + reason: str | None = None + + +class _PdfSignatureConfigurationModel(BaseModel): + type: Literal["new"] + name: str | None = None + logo_opacity: Annotated[float | None, Field(ge=0, le=1, default=None)] = None + location: _PdfSignatureLocationModel | None = None + display: _PdfSignatureDisplayModel | None = None + + @model_validator(mode="after") + def _validate_location_for_new_type(self) -> _PdfSignatureConfigurationModel: + if self.type == "new" and self.location is None: + msg = "Missing location information for a new digital signature field." + raise ValueError(msg) + return self + + _PdfRedactionVariant = Annotated[ PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel, Field(discriminator="type"), @@ -986,6 +1028,158 @@ class PdfFlattenFormsPayload(BaseModel): ] = None +class PdfSignPayload(BaseModel): + """Adapt caller options into a pdfRest-ready sign request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + signature_configuration: Annotated[ + _PdfSignatureConfigurationModel, + Field(serialization_alias="signature_configuration"), + PlainSerializer(_serialize_signature_configuration), + ] + pfx_credential: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("pfx", "pfx_credential"), + serialization_alias="pfx_credential_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/x-pkcs12", + "application/pkcs12", + "application/octet-stream", + error_msg="PFX credentials must be a .pfx or .p12 file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + pfx_passphrase: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("pfx_passphrase", "passphrase"), + serialization_alias="pfx_passphrase_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "text/plain", + "application/octet-stream", + error_msg="PFX passphrase must be a text file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + certificate: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("certificate", "cert"), + serialization_alias="certificate_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/pkix-cert", + "application/x-x509-ca-cert", + "application/x-pem-file", + "application/octet-stream", + error_msg="Certificate must be a .pem or .der file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + private_key: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("private_key", "key"), + serialization_alias="private_key_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/pkcs8", + "application/x-pem-file", + "application/octet-stream", + error_msg="Private key must be a .pem or .der file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + logo: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("logo", "logos"), + serialization_alias="logo_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "image/jpeg", + "image/png", + "image/tiff", + "image/bmp", + error_msg="Logo must be an image file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="after") + def _validate_credentials(self) -> PdfSignPayload: + has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None + has_pem = self.certificate is not None or self.private_key is not None + + if has_pfx and has_pem: + msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both." + raise ValueError(msg) + if has_pfx: + if not self.pfx_credential or not self.pfx_passphrase: + msg = "Both pfx and passphrase are required when supplying PFX credentials." + raise ValueError(msg) + elif has_pem: + if not self.certificate or not self.private_key: + msg = "Both certificate and private_key are required when supplying PEM/DER credentials." + raise ValueError(msg) + else: + msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required." + raise ValueError(msg) + + return self + + class PdfCompressPayload(BaseModel): """Adapt caller options into a pdfRest-ready compress request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index f305cd9e..8e2c0699 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -24,6 +24,10 @@ PdfRedactionType, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, + PdfSignatureDisplay, + PdfSignatureLocation, PdfXType, PngColorModel, SummaryFormat, @@ -57,6 +61,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfXType", "PngColorModel", "SummaryFormat", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 27821ec4..629d0bfb 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -36,6 +36,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfXType", "PngColorModel", "SummaryFormat", @@ -136,6 +140,47 @@ class PdfMergeSource(TypedDict, total=False): PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection] + +class PdfSignaturePoint(TypedDict): + x: str | int | float + y: str | int | float + + +class PdfSignatureLocation(TypedDict): + bottom_left: Required[PdfSignaturePoint] + top_right: Required[PdfSignaturePoint] + page: Required[str | int] + + +class PdfSignatureDisplay(TypedDict, total=False): + include_distinguished_name: bool + include_datetime: bool + contact: str + location: str + name: str + reason: str + + +class PdfSignatureConfiguration(TypedDict, total=False): + type: Required[Literal["new"]] + location: Required[PdfSignatureLocation] + name: str + logo_opacity: float + display: PdfSignatureDisplay + + +class PdfPfxCredentials(TypedDict): + pfx: Required[PdfRestFile] + passphrase: Required[PdfRestFile] + + +class PdfPemCredentials(TypedDict): + certificate: Required[PdfRestFile] + private_key: Required[PdfRestFile] + + +PdfSignatureCredentials = PdfPfxCredentials | PdfPemCredentials + PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] ExtractTextGranularity = Literal["off", "by_page", "document"] diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py new file mode 100644 index 00000000..3188d009 --- /dev/null +++ b/tests/live/test_live_sign_pdf.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +def make_signature_location() -> dict[str, dict[str, int] | int]: + return { + "bottom_left": {"x": 0, "y": 0}, + "top_right": {"x": 216, "y": 72}, + "page": 1, + } + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_signing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_pfx_credential( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_credentials.pfx") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_passphrase( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_passphrase.txt") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_certificate( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_certificate.pem") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_private_key( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_private_key.der") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_logo( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_logo.png") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +def test_live_sign_pdf_with_pfx_credentials( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + signature_configuration = { + "type": "new", + "name": "pdfrest-live", + "location": make_signature_location(), + } + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration=signature_configuration, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-signed-pfx", + ) + + assert response.output_file.type == "application/pdf" + assert response.output_file.name == "live-signed-pfx.pdf" + assert str(uploaded_pdf_for_signing.id) in response.input_ids + + +@pytest.mark.asyncio +async def test_live_async_sign_pdf_with_certificate( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_certificate: PdfRestFile, + uploaded_private_key: PdfRestFile, + uploaded_logo: PdfRestFile, +) -> None: + signature_configuration = { + "type": "new", + "logo_opacity": 0.5, + "location": { + "bottom_left": {"x": 0, "y": 0}, + "top_right": {"x": 216, "y": 72}, + "page": 1, + }, + } + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = await client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration=signature_configuration, + credentials={ + "certificate": uploaded_certificate, + "private_key": uploaded_private_key, + }, + logo=uploaded_logo, + output="live-signed-cert", + ) + + assert response.output_file.type == "application/pdf" + assert response.output_file.name == "live-signed-cert.pdf" + assert uploaded_logo.id in response.input_ids + + +def test_live_sign_pdf_invalid_signature_configuration( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError), + ): + client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + extra_body={"signature_configuration": "not-json"}, + ) diff --git a/tests/resources/signing_certificate.pem b/tests/resources/signing_certificate.pem new file mode 100644 index 00000000..226801fc --- /dev/null +++ b/tests/resources/signing_certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUF0/LIPW1an831Oa3vegdRgAcvZgwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MB4XDTI2MDExNjIyNDQwMVoXDTM2 +MDExNDIyNDQwMVowFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEApp1yT4py9K3TGZa7PFALZmfmkG8EBvK1WFg2 +jDO7p7jgyeM5a8m91fHdq7MJcTgti3UUoogMwj1wQKRGWzoVyGNLPL4f0UyV7JdW +mzzMjSQhFbc0ZPmZTS2mHNbbf0BgVsmnmvkXzwueV52reZlTaOTwG3M2pJxyb/+f +YXkv8C8CT4dygNXrBm9A6wlvWEX6z5UaniJzMaFTckK1kl2nHceE0W7kXGqQCGzb +Sff8IRH7LAiQaQ29UFdCE3v6kXxaM+HQ7FrHdm80eIJ8YPvxxW2iO+BYYRYnrMeF +5tSeFyDKYbazzjeWDYptnuySyrcQH9IpknqbRXq8L6bj8u21HQIDAQABo1MwUTAd +BgNVHQ4EFgQUhV4AOiZwlC4Uiv3YZfG248wTyiAwHwYDVR0jBBgwFoAUhV4AOiZw +lC4Uiv3YZfG248wTyiAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAkxFvZkOBBpjxGf4lhSEMP3iCm12SMUtc4uDJsyxU3tjsFjIvnmyvr5LTcG0F +pAl6BWaJj2Ex4/mQL6ztqJRy5GINI5ycBaXQAdEyUDY4+SEaYZKPNfCZ1lDAzD+C +LotVxPAiYnxS2pI9n93Av+HThLtkbr8gRpYhND9mGIWxFGxMlIqgWYtmk2frF325 +e8oIKIMUluESo3GIGwHMW1SCx7oxxfbxsP3oOQzGQtAHxouaZjXtCAw7vdtZAuRy +8YbSz1mt5G/S07to9O/NxsvvKum7zY1g38R9j823F4plhHwpyNUlE0yuofH3lxZA +HES2QmYhPJPxHNtmXLFmGFYT8Q== +-----END CERTIFICATE----- diff --git a/tests/resources/signing_credentials.pfx b/tests/resources/signing_credentials.pfx new file mode 100644 index 00000000..b42c7348 Binary files /dev/null and b/tests/resources/signing_credentials.pfx differ diff --git a/tests/resources/signing_key.pem b/tests/resources/signing_key.pem new file mode 100644 index 00000000..c6f604f8 --- /dev/null +++ b/tests/resources/signing_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmnXJPinL0rdMZ +lrs8UAtmZ+aQbwQG8rVYWDaMM7unuODJ4zlryb3V8d2rswlxOC2LdRSiiAzCPXBA +pEZbOhXIY0s8vh/RTJXsl1abPMyNJCEVtzRk+ZlNLaYc1tt/QGBWyaea+RfPC55X +nat5mVNo5PAbczaknHJv/59heS/wLwJPh3KA1esGb0DrCW9YRfrPlRqeInMxoVNy +QrWSXacdx4TRbuRcapAIbNtJ9/whEfssCJBpDb1QV0ITe/qRfFoz4dDsWsd2bzR4 +gnxg+/HFbaI74FhhFiesx4Xm1J4XIMphtrPON5YNim2e7JLKtxAf0imSeptFerwv +puPy7bUdAgMBAAECggEASikJcNsEiOD39ctQIqvULywvBXnMdpVAX4bAHM6IB8L0 +Fxhy/gWpYBmMW7jQipsBNrIR0bgxyaFUHgmgoUls2alMm0ha3Cu1Db5c17MLrwT2 +TvahNRKeCCq55dtCjtTmLKsMVZ/q14bp30C4SuMSq70/HFC/cSyLiUtjsxygWEy0 +Uhrjj9lENMtrGY4cOg4E9pb4oyUVQXTEgznVAHawieJIhXaRvbqbo5QLNlEs0xCr +n77v6AIbXs455au/37tQOgprRPQlkzgzaBtBsgth0QITXwgWc3HZEmIFASWEZuHI +G48OEfaon/QxUdMkST92KVc1LhPXNWjG6rAfy+PbJwKBgQDUklubcecj57wDYqsv +X6NaXGjPUqdyuGB1u3Vdzx44uGyPMCPevqp+zcvLbM9ksBpR3RCocvfRJnNWSpFZ +DIe8i264lqCiCCiSiMb13Socoey0zlHknDFxEEAz49CuzRYGl838Tv4mV34h4d2g +SQ0OyaWyvAisFDs+0BI6u/NjuwKBgQDIp4X/rQifY/ar7jPxWH9kGLFyIcioNXMr +md1aRSBYvrJKSjQngIgsl0aR+Bsxm2LfH6Jxta20TiOBdLJLBxsvyuy+vw2LT/lx +Nr8TuyotYvj3bEDqoF6YGGWAyU3k2d/gatm57GHrUwe3fo0+9+D98klZpo8GNIjk +xbp9cuLBBwKBgD17M0mrUQn+fU+RWyexhqKc9ad5JXs1vphupoyCWiBXnvZvGwDS +rqdMSHRGvVlG4eXphWbjEbAJafR8TruttxieT2DOGBmlOG7hZoI3/HUZlEfbIK55 +SoeEBr27V2EnagZwI6ClDDb0uUN9e0dfuYocYnNmlS+IDnalYZBhSgz/AoGABiak +g+7w+bndwO1/aCGXXiEnp2EDvqxMyIRh9bdyw2WtH3vg12koQ32rqyPY6Y9i24Yj +u6qfFYzjp79FC+m+2ps04LAIoUGlWuQbvWYaZ+PF0AfggZDC9ZSh3+3L1n0bUMzV +uc5WPhmAfg6CE/ETU5WOzBHABqernp+1FM1lyBcCgYBpv/G3nmuLGJxE7ZdEv21H +oKQeMuzT74u3RbLuLYCjRXOa7XEGHN/ehl27MZvXc4Fl4VbPqR29tPVQ1dAyehfu +0GQZiJuS4Z+o7P5/BVmK/W98NTOgUHCiF2BNO4WND3qRev7rZqMdKtZgJ+cJvZ98 +9rhoMDzdXrqa8CzDOfqEbw== +-----END PRIVATE KEY----- diff --git a/tests/resources/signing_logo.png b/tests/resources/signing_logo.png new file mode 100644 index 00000000..c9b56ad7 Binary files /dev/null and b/tests/resources/signing_logo.png differ diff --git a/tests/resources/signing_passphrase.txt b/tests/resources/signing_passphrase.txt new file mode 100644 index 00000000..f3097ab1 --- /dev/null +++ b/tests/resources/signing_passphrase.txt @@ -0,0 +1 @@ +password diff --git a/tests/resources/signing_private_key.der b/tests/resources/signing_private_key.der new file mode 100644 index 00000000..87a27ef3 Binary files /dev/null and b/tests/resources/signing_private_key.der differ diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py new file mode 100644 index 00000000..5b1ffb16 --- /dev/null +++ b/tests/test_sign_pdf.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfSignPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def make_pfx_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "signer.pfx", "application/x-pkcs12") + ) + + +def make_passphrase_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "passphrase.txt", "text/plain") + ) + + +def make_certificate_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "certificate.pem", "application/pkix-cert") + ) + + +def make_private_key_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "private_key.der", "application/pkcs8") + ) + + +def make_logo_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "logo.png", "image/png") + ) + + +def make_signature_location() -> dict[str, dict[str, int] | int]: + return { + "bottom_left": {"x": 0, "y": 0}, + "top_right": {"x": 216, "y": 72}, + "page": 1, + } + + +def test_sign_pdf_with_pfx_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(1)) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + signature_configuration = { + "type": "new", + "name": "esignature", + "location": make_signature_location(), + "display": {"include_datetime": True, "name": "Signer"}, + } + payload_dump = PdfSignPayload.model_validate( + { + "files": [input_file], + "signature_configuration": signature_configuration, + "pfx_credential": [pfx_file], + "pfx_passphrase": [passphrase_file], + "output": "signed-pdf", + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/signed-pdf": + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [input_file.id, pfx_file.id], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["format"] == "info" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "signed-pdf.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.sign_pdf( + input_file, + signature_configuration=signature_configuration, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + output="signed-pdf", + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "signed-pdf.pdf" + assert str(input_file.id) in {str(item) for item in response.input_ids} + + +def test_sign_pdf_with_certificate_credentials_and_logo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate(2)) + certificate_file = make_certificate_file(str(PdfRestFileID.generate())) + private_key_file = make_private_key_file(str(PdfRestFileID.generate())) + logo_file = make_logo_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + + signature_configuration = { + "type": "new", + "name": "visible signature", + "location": { + "bottom_left": {"x": 0, "y": 0}, + "top_right": {"x": 216, "y": 72}, + "page": 1, + }, + } + payload_dump = PdfSignPayload.model_validate( + { + "files": [input_file], + "signature_configuration": signature_configuration, + "certificate": [certificate_file], + "private_key": [private_key_file], + "logo": [logo_file], + } + ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/signed-pdf": + payload = json.loads(request.content.decode("utf-8")) + assert payload == payload_dump + return httpx.Response( + 200, + json={ + "inputId": [ + input_file.id, + certificate_file.id, + private_key_file.id, + logo_file.id, + ], + "outputId": [output_id], + }, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, "signed.pdf", "application/pdf" + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + with PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client: + response = client.sign_pdf( + input_file, + signature_configuration=signature_configuration, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + logo=logo_file, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "signed.pdf" + assert logo_file.id in response.input_ids + + +def test_sign_pdf_requires_credential_pair( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=r"pfx.*passphrase"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file}, + ) + + +def test_sign_pdf_requires_location_for_new_signature_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises( + ValidationError, + match=r"Missing location information for a new digital signature field", + ), + ): + client.sign_pdf( + input_file, + signature_configuration={"type": "new"}, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + +@pytest.mark.asyncio +async def test_async_sign_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + certificate_file = make_certificate_file(str(PdfRestFileID.generate())) + private_key_file = make_private_key_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/signed-pdf": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async-sign" + captured_timeout["value"] = request.extensions.get("timeout") + payload = json.loads(request.content.decode("utf-8")) + assert payload["certificate_id"] == str(certificate_file.id) + assert payload["private_key_id"] == str(private_key_file.id) + assert json.loads(payload["signature_configuration"])["type"] == "new" + assert payload["output"] == "async-signed" + assert payload["diagnostics"] == "on" + return httpx.Response( + 200, + json={"inputId": [input_file.id], "outputId": [output_id]}, + ) + if request.method == "GET" and request.url.path == f"/resource/{output_id}": + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async-sign" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-signed.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=transport, + ) as client: + response = await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + output="async-signed", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async-sign"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-signed.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.5) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.5)