From 8238b1f0394306739095ab7bce95d7ed0cab1348 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 20 Jan 2026 12:00:28 -0600 Subject: [PATCH 01/23] Add Sign PDF tool Assisted-by: Codex --- src/pdfrest/client.py | 75 +++++++++++++ src/pdfrest/models/_internal.py | 187 ++++++++++++++++++++++++++++++++ src/pdfrest/types/__init__.py | 8 ++ src/pdfrest/types/public.py | 46 ++++++++ 4 files changed, 316 insertions(+) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 05976b6..8520179 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -111,6 +111,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfRestrictPayload, + PdfSignPayload, PdfSplitPayload, PdfTextWatermarkPayload, PdfToExcelPayload, @@ -156,6 +157,8 @@ PdfRedactionInstruction, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, PdfTextColor, PdfXType, PngColorModel, @@ -3212,6 +3215,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 blank_pdf( self, *, @@ -5109,6 +5148,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 blank_pdf( self, *, diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3e5f7f0..8cc289f 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -229,6 +229,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]: @@ -978,6 +985,34 @@ 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 + + _PdfRedactionVariant = Annotated[ PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel, Field(discriminator="type"), @@ -1435,6 +1470,158 @@ class PdfExportFormDataPayload(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 aafe245..389e334 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -36,6 +36,10 @@ PdfRedactionType, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, + PdfSignatureDisplay, + PdfSignatureLocation, PdfTextColor, PdfXType, PngColorModel, @@ -84,6 +88,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfTextColor", "PdfXType", "PngColorModel", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 09683eb..5674dd8 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -48,6 +48,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfTextColor", "PdfXType", "PngColorModel", @@ -162,6 +166,48 @@ class PdfMergeSource(TypedDict, total=False): HtmlPageSize = Literal["letter", "legal", "ledger", "A3", "A4", "A5"] HtmlPageOrientation = Literal["portrait", "landscape"] HtmlWebLayout = Literal["desktop", "tablet", "mobile"] + + +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"]] + name: str + logo_opacity: float + location: PdfSignatureLocation + 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"] From 9f9fa4ae167d6b43a13a353d108c192b5a1699ca Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 20 Jan 2026 15:11:46 -0600 Subject: [PATCH 02/23] Add Sign PDF tests --- tests/live/test_live_sign_pdf.py | 179 ++++++++++++++++ tests/resources/signing_certificate.pem | 19 ++ tests/resources/signing_credentials.pfx | Bin 0 -> 2555 bytes tests/resources/signing_key.pem | 28 +++ tests/resources/signing_logo.png | Bin 0 -> 68 bytes tests/resources/signing_passphrase.txt | 1 + tests/resources/signing_private_key.der | Bin 0 -> 1216 bytes tests/test_sign_pdf.py | 267 ++++++++++++++++++++++++ 8 files changed, 494 insertions(+) create mode 100644 tests/live/test_live_sign_pdf.py create mode 100644 tests/resources/signing_certificate.pem create mode 100644 tests/resources/signing_credentials.pfx create mode 100644 tests/resources/signing_key.pem create mode 100644 tests/resources/signing_logo.png create mode 100644 tests/resources/signing_passphrase.txt create mode 100644 tests/resources/signing_private_key.der create mode 100644 tests/test_sign_pdf.py diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py new file mode 100644 index 0000000..68a9cf9 --- /dev/null +++ b/tests/live/test_live_sign_pdf.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient +from pdfrest.models import PdfRestFile + +from ..resources import get_test_resource_path + + +@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", + } + 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"}, + 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 0000000..226801f --- /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 0000000000000000000000000000000000000000..b42c73483b9bbb6619b8d9235267bad68be3b3f8 GIT binary patch literal 2555 zcmai$X*d)L7st&QqcJl~vLw?WTlTR_B(lrCGxmL($W|gu%*{^ONrcH&DMo}y_AO+| zGTpJoSjLu-y{`Mb@6-KwKb+@0=lB0`KAqol&^X8*5I~Q{LCV1pQM@jG4-Q}i6yqS- zAROe;DNaS>=nwymz{NQFwNpd^0_aZL?7s;B?eu$KfTPXO(*KSyv;YVp5RHWOZLGDy zin_;oCl^zIz%z7c82HTpwgMpxbm)sNgBO?9)QnT%6f;QrPa z9DQKGVz=5Mvlg?4D#pcvfJ7NoJWEb8k5&jMGP|_Pmip3YEgl?Wv>fqVTCPNDKlZQ5 zE%Gc$G5QMkyaBiDD{qrN@eNv0<)nLI=B!w!`mgp`d82+LHM>NvX=qM~CHE<9);RBJ z7ig4GY&g2^fs|5$w-w2FW8CzoATd(lT4g4Iwr^}G$YR8)8NujY;(i#mO3 zuh3L^!oL7l_J@bjr^IS zg8;bmwQz^E#!OU6`z;sR&q}0ZK>^&KsnBq*(n(DqexQ2ml5gOOlU+CiK7*WWKm;#^ z28ZHg^^l!P|3oi2fD~Ysop0bhp4m%%CRPcBa0%=i>F-MI&mTxfb zBb;TMnf}06o;{8mZV&C`s_Pkkm_$Ur9eh0JS+DA5f6dQ#K-73JX1D|fdohA29`Rx3!lW0M4IOzdkd29gXU3SeS6}X# zB$f?d<7j6*2&P3K&P^yZLg4JS{3W+AybJ0H&bbR&f(nD&%TkueEfcx1hbI?Ui{H|{ zlq2jIX>=P&qHo5D?8a}gX4@5kuE+}PTw#{q@4gqy>S!3ppYLr*IR4E?_|U`znuX?W!n%Kw)LvZEO#bVNng@TwORB@RSI z{!;`$GW&&f!<2#AjMS-^kr@XN4m3=u8hJ=Dsr#J9g=*}#tB@;7Gp(60t^Z=UPFPbB%d(|8iGJi-H1$waW! zH-R0DU0TV#y~-4-G(u$3R2qx=xS40M?_KuFbtY8AK(qN?l$L+_j9$lBhvaJlvz@u< zR~vh)^sE|?jf<+{xKSxRvy^?A`vHxJ#h7r!s&B!MG#&%B{*bt_%#Yr*D~eTTR`MhLXA+GM6%X$RO6uMYh_C+A}O-r`et#CJiqSBhRz~q{=pZ5lk?|m zUaOVGEyV1eoOpZD@gZ+ zQ>bx~Rr=bweaJ(&wG$j1ElM2RYzWvbjT#sK?3IMb7%SFq6MS}J`v5TsdDOpro!8Cj z(TvnlI*M6j>bSZsp`~DTs+%y>JHId@8-VPkZA8pCQrJ+K?W? z#{EXvXf8NRpVf)FE`E)PI~P2x%odC)g{DY}iDeg&uX23gj7`91OCmU4D6--#&`cnq)7z@9=*dB*UWUc8>1_kymr`pg$Tp*m^C zl2nCtc_tm+@Z&}IYThvTryxI)!F6S)Bo~9VtJ*@61qACTv*9cb6!9#Ut&#dOhoU++ zmOrRTtdl4=*GH*lS(ok0*hGBHZ2`mhr>5akexyu~%h9zL`;Ekd7j!Crsn~aN0r&|l zZEs*rlqH%#(YKk<@V6vhY1u21Dgp9jywM(1uk}O5f{2%*3679f6_Z_5)gbcTw_fcy zsC^?rS`f|uTeJ{%X$}w@*JjOpnMJBqLaVVDxWz`*e15GEt%sII!+*a?KmY?Bh__{m z&)l7Y*f;QX@rV!>qV-iV>&cu+IcgPN4b9k Dv6G>E literal 0 HcmV?d00001 diff --git a/tests/resources/signing_key.pem b/tests/resources/signing_key.pem new file mode 100644 index 0000000..c6f604f --- /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 0000000000000000000000000000000000000000..c9b56ad70a1ec48aef49b35cbc30af8202ed7a25 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcwN$fBwreFf%hTyt^e9 Q0TgENboFyt=akR{0D@u-_W%F@ literal 0 HcmV?d00001 diff --git a/tests/resources/signing_passphrase.txt b/tests/resources/signing_passphrase.txt new file mode 100644 index 0000000..f3097ab --- /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 0000000000000000000000000000000000000000..87a27ef3d4e5099eca76243ea1a64781f176c887 GIT binary patch literal 1216 zcmV;x1V8&Qf&{z*0RS)!1_>&LNQUrrZ9p8q5=T`0)hbn0H&RCPl|H% zt@^oBq0^IG-UaiO)aJz*4uwTU{=Yen)w&c3!Yb{`cmZ!~yx|^ev3pPyWVyiD-qgq^O&r+vyxL|d=bzRRMIJj(&FeBc+s(#JO%WThNuo_X_5U6tZ z(I#_NN|9L%hrEk!xR#)z2q=<>#`WDQ9HH#A&Qau?F>w$;Gvm;%%@zii&HPUOCRctT z;oYD~4Gzhrvb+ea6gxi95<0u{W4i)@fdI&-h5xMxpJVo`?lbXNe`FZ3av{j5HFGPO z-C9K;SiZ7KN;D^ch%A>zk@y=in_}M|qH(pYv`!;|bh1ka8!yW2zP}BNPx)~+zZ1JE zEn@ihY(VOuUYHnVfXPkd+27!5*}3dt>r)4}evLl&;QjJRS*DK$G>GKIx_xru!3P3? zfIWLNNvlx_{(Vo8TPLxGqMY@oc_n)_zL;*NjDlJqSDyB78vxR-r%Xt6M!i`^;pORt zX5$gC32F3vPP?tQ7@kjH&KMb`IBwx)f;aqi8I(ucAg*~zhlB>byH{Z+Y6fs4prs5p z^tnTQdq-cniX38dW|c394tAwskYP#;{{n%41}3C~?(q4!-N5aCXd#zgAt$F{1HP66C2%$lxTI3tO zW*TSX#n1=ffsn%Wl%e14%hr7xP|Vf2&Q?AdfPM~w6Y&#Mm5$62zy_zQo}aZ8&1J|J z0)c>Ozwx)8Yl|41MD3SEzimgLq#iQt)9;J7MY8TKfTKlon(c809N*rCUAr-x*K>ho z;a1P79lf;mP}R^fdKd1{WEqH?lHs4I?EZfRS&IE{d^IzmP;jCbU`;!PjSqT}dj9KX eqa7;NU?=AZy`OycxM(mu-Cnwy@GQeQ`h;&bDM`-& literal 0 HcmV?d00001 diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py new file mode 100644 index 0000000..d9cdbce --- /dev/null +++ b/tests/test_sign_pdf.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from pdfrest import AsyncPdfRestClient, PdfRestClient, PdfRestConfigurationError +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 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", + "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(3)) + 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(PdfRestConfigurationError, match=r"pfx.*passphrase"), + ): + client.sign_pdf( + input_file, + signature_configuration={"type": "new"}, + credentials={"pfx": pfx_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(4)) + 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"}, + 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) From b681ae4a154a43eef42b44ba6c2731019eca448e Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 16 Feb 2026 14:54:26 -0600 Subject: [PATCH 03/23] signed-pdf: Require `location` for new signature fields Assisted-by: Codex --- src/pdfrest/models/_internal.py | 7 +++++ src/pdfrest/types/public.py | 2 +- tests/live/test_live_sign_pdf.py | 14 ++++++++- tests/test_sign_pdf.py | 51 ++++++++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 8cc289f..b0a40c8 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1012,6 +1012,13 @@ class _PdfSignatureConfigurationModel(BaseModel): 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, diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 5674dd8..b4bbf67 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -190,9 +190,9 @@ class PdfSignatureDisplay(TypedDict, total=False): class PdfSignatureConfiguration(TypedDict, total=False): type: Required[Literal["new"]] + location: Required[PdfSignatureLocation] name: str logo_opacity: float - location: PdfSignatureLocation display: PdfSignatureDisplay diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 68a9cf9..3188d00 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -8,6 +8,14 @@ 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, @@ -96,6 +104,7 @@ def test_live_sign_pdf_with_pfx_credentials( signature_configuration = { "type": "new", "name": "pdfrest-live", + "location": make_signature_location(), } with PdfRestClient( api_key=pdfrest_api_key, @@ -170,7 +179,10 @@ def test_live_sign_pdf_invalid_signature_configuration( ): client.sign_pdf( uploaded_pdf_for_signing, - signature_configuration={"type": "new"}, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, credentials={ "pfx": uploaded_pfx_credential, "passphrase": uploaded_passphrase, diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index d9cdbce..5b1ffb1 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -4,8 +4,9 @@ import httpx import pytest +from pydantic import ValidationError -from pdfrest import AsyncPdfRestClient, PdfRestClient, PdfRestConfigurationError +from pdfrest import AsyncPdfRestClient, PdfRestClient from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID from pdfrest.models._internal import PdfSignPayload @@ -47,6 +48,14 @@ def make_logo_file(file_id: str) -> PdfRestFile: ) +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)) @@ -57,6 +66,7 @@ def test_sign_pdf_with_pfx_credentials(monkeypatch: pytest.MonkeyPatch) -> None: signature_configuration = { "type": "new", "name": "esignature", + "location": make_signature_location(), "display": {"include_datetime": True, "name": "Signer"}, } payload_dump = PdfSignPayload.model_validate( @@ -183,27 +193,53 @@ 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(3)) + 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(PdfRestConfigurationError, match=r"pfx.*passphrase"), + pytest.raises(ValidationError, match=r"pfx.*passphrase"), ): client.sign_pdf( input_file, - signature_configuration={"type": "new"}, + 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(4)) + 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()) @@ -245,7 +281,10 @@ def handler(request: httpx.Request) -> httpx.Response: ) as client: response = await client.sign_pdf( input_file, - signature_configuration={"type": "new"}, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, credentials={ "certificate": certificate_file, "private_key": private_key_file, From ab1848074d386239a6caeb39399a288e8932144f Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 12 Feb 2026 14:41:59 -0600 Subject: [PATCH 04/23] client, models: Simplify credential payload handling for PDF signing - Refactored the credentials logic in `client.py` to remove redundant validation and mapping code. - Added a `_normalize_credentials` model validator in `_internal.py` to handle credential normalization and enforce valid inputs. - Updated test cases in `test_sign_pdf.py` to align with the new credentials structure, consolidating `pfx` and `certificate` data under a unified `credentials` field. Assisted-by: Codex --- src/pdfrest/client.py | 4 ++-- src/pdfrest/models/_internal.py | 25 +++++++++++++++++++++++++ tests/test_sign_pdf.py | 9 +++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 8520179..49f65d8 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -3233,8 +3233,8 @@ def sign_pdf( payload: dict[str, Any] = { "files": file, "signature_configuration": signature_configuration, + "credentials": credentials, } - payload.update(credentials) if logo is not None: payload["logo"] = logo @@ -5166,8 +5166,8 @@ async def sign_pdf( payload: dict[str, Any] = { "files": file, "signature_configuration": signature_configuration, + "credentials": credentials, } - payload.update(credentials) if logo is not None: payload["logo"] = logo diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index b0a40c8..aed34f5 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1606,6 +1606,31 @@ class PdfSignPayload(BaseModel): AfterValidator(_validate_output_prefix), ] = None + @model_validator(mode="before") + @classmethod + def _normalize_credentials(cls, data: Any) -> Any: + if not isinstance(data, Mapping): + return data + + payload = cast(Mapping[object, Any], data) + credentials = payload.get("credentials") + if credentials is None: + return {str(key): value for key, value in payload.items()} + if not isinstance(credentials, Mapping): + msg = ( + "credentials must be a mapping with either pfx/passphrase or " + "certificate/private_key." + ) + raise TypeError(msg) + + normalized: dict[str, Any] = {str(key): value for key, value in payload.items()} + credential_map = cast(Mapping[object, Any], credentials) + for raw_key, value in credential_map.items(): + key = str(raw_key) + if key not in normalized: + normalized[key] = value + return normalized + @model_validator(mode="after") def _validate_credentials(self) -> PdfSignPayload: has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 5b1ffb1..d29bdf8 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -73,8 +73,7 @@ def test_sign_pdf_with_pfx_credentials(monkeypatch: pytest.MonkeyPatch) -> None: { "files": [input_file], "signature_configuration": signature_configuration, - "pfx_credential": [pfx_file], - "pfx_passphrase": [passphrase_file], + "credentials": {"pfx": pfx_file, "passphrase": passphrase_file}, "output": "signed-pdf", } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) @@ -140,8 +139,10 @@ def test_sign_pdf_with_certificate_credentials_and_logo( { "files": [input_file], "signature_configuration": signature_configuration, - "certificate": [certificate_file], - "private_key": [private_key_file], + "credentials": { + "certificate": certificate_file, + "private_key": private_key_file, + }, "logo": [logo_file], } ).model_dump(mode="json", by_alias=True, exclude_none=True, exclude_unset=True) From d02fcd852a77f80d3dafb54cf925cc742ab693d8 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 12 Feb 2026 14:42:14 -0600 Subject: [PATCH 05/23] models: Simplify signature configuration serialization - Replaced manual JSON construction with the built-in `model_dump_json` method, streamlining and optimizing the serialization process. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index aed34f5..3852cc4 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -232,8 +232,7 @@ def _serialize_text_objects(value: list[BaseModel]) -> str: def _serialize_signature_configuration( value: _PdfSignatureConfigurationModel, ) -> str: - payload = value.model_dump(mode="json", exclude_none=True) - return to_json(payload).decode() + return value.model_dump_json(exclude_none=True) def _allowed_mime_types( From afc16e5c01c323aee5afc2dfbbf12d6e3edebf13 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 12 Feb 2026 17:12:30 -0600 Subject: [PATCH 06/23] models: Add location validation for new signature configurations - Updated `_PdfSignatureConfigurationModel` to enforce location requirements for `type="new"` via a model validator. - Extended `type` field to include `"existing"` in both the model and the public `PdfSignatureConfiguration` type. - Added tests to verify: - Validation error is raised when `type="new"` and location is missing. - `type="existing"` does not require location fields. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 9 ++++++--- src/pdfrest/types/public.py | 2 +- tests/test_sign_pdf.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3852cc4..33a6e3f 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1005,16 +1005,19 @@ class _PdfSignatureDisplayModel(BaseModel): class _PdfSignatureConfigurationModel(BaseModel): - type: Literal["new"] + type: Literal["new", "existing"] 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: + def _validate_location_requirements(self) -> _PdfSignatureConfigurationModel: if self.type == "new" and self.location is None: - msg = "Missing location information for a new digital signature field." + msg = ( + "Missing location information for a new digital signature field. " + "See documentation for required fields." + ) raise ValueError(msg) return self diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index b4bbf67..54fa169 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -189,7 +189,7 @@ class PdfSignatureDisplay(TypedDict, total=False): class PdfSignatureConfiguration(TypedDict, total=False): - type: Required[Literal["new"]] + type: Required[Literal["new", "existing"]] location: Required[PdfSignatureLocation] name: str logo_opacity: float diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index d29bdf8..51b9a2f 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -305,3 +305,36 @@ def handler(request: httpx.Request) -> httpx.Response: assert all(pytest.approx(0.5) == value for value in timeout_value.values()) else: assert timeout_value == pytest.approx(0.5) + + +def test_sign_payload_requires_location_when_type_new() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + + with pytest.raises( + ValidationError, + match=r"Missing location information for a new digital signature field", + ): + PdfSignPayload.model_validate( + { + "files": [input_file], + "signature_configuration": {"type": "new", "name": "sig"}, + "credentials": {"pfx": pfx_file, "passphrase": passphrase_file}, + } + ) + + +def test_sign_payload_allows_existing_without_location() -> None: + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + + payload = PdfSignPayload.model_validate( + { + "files": [input_file], + "signature_configuration": {"type": "existing", "name": "sig"}, + "credentials": {"pfx": pfx_file, "passphrase": passphrase_file}, + } + ) + assert payload.signature_configuration.type == "existing" From f373474f8888b64fbbcc9ad5fc83e66eb8cb8fa0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 12 Feb 2026 17:24:49 -0600 Subject: [PATCH 07/23] tests/live: Update signature location in test_live_sign_pdf - Adjusted `location` coordinates in `test_live_sign_pdf` to begin at `(1, 1)` instead of `(0, 0)` for improved consistency. - Add required location for new signature. - Updated test configurations to reflect the changes in signature naming and layout. Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 3188d00..9c687af 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -10,8 +10,8 @@ def make_signature_location() -> dict[str, dict[str, int] | int]: return { - "bottom_left": {"x": 0, "y": 0}, - "top_right": {"x": 216, "y": 72}, + "bottom_left": {"x": 1, "y": 1}, + "top_right": {"x": 217, "y": 73}, "page": 1, } @@ -136,10 +136,11 @@ async def test_live_async_sign_pdf_with_certificate( ) -> None: signature_configuration = { "type": "new", + "name": "live-async-signature", "logo_opacity": 0.5, "location": { - "bottom_left": {"x": 0, "y": 0}, - "top_right": {"x": 216, "y": 72}, + "bottom_left": {"x": 1, "y": 1}, + "top_right": {"x": 217, "y": 73}, "page": 1, }, } From 8672a323942055ea04595d9f186514b885e1a769 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 12 Feb 2026 17:25:17 -0600 Subject: [PATCH 08/23] tests/live: Add test for signing PDF with existing signature field - Introduced `test_live_sign_pdf_with_existing_signature_field` to verify signing a PDF with an existing signature field. - Tested both "new" and "existing" signature types for compatibility. - Ensured proper validation of output file type, name, and input IDs. Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 47 ++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 9c687af..06e271c 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -125,6 +125,47 @@ def test_live_sign_pdf_with_pfx_credentials( assert str(uploaded_pdf_for_signing.id) in response.input_ids +def test_live_sign_pdf_with_existing_signature_field( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_certificate: PdfRestFile, + uploaded_private_key: PdfRestFile, +) -> None: + signature_name = "sdk-existing-live" + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + first_response = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": signature_name, + "location": make_signature_location(), + }, + credentials={ + "certificate": uploaded_certificate, + "private_key": uploaded_private_key, + }, + output="live-signed-new-for-existing", + ) + + existing_response = client.sign_pdf( + first_response.output_file, + signature_configuration={"type": "existing", "name": signature_name}, + credentials={ + "certificate": uploaded_certificate, + "private_key": uploaded_private_key, + }, + output="live-signed-existing", + ) + + assert existing_response.output_file.type == "application/pdf" + assert existing_response.output_file.name == "live-signed-existing.pdf" + assert str(first_response.output_file.id) in existing_response.input_ids + + @pytest.mark.asyncio async def test_live_async_sign_pdf_with_certificate( pdfrest_api_key: str, @@ -138,11 +179,7 @@ async def test_live_async_sign_pdf_with_certificate( "type": "new", "name": "live-async-signature", "logo_opacity": 0.5, - "location": { - "bottom_left": {"x": 1, "y": 1}, - "top_right": {"x": 217, "y": 73}, - "page": 1, - }, + "location": make_signature_location(), } async with AsyncPdfRestClient( api_key=pdfrest_api_key, From cf34f16b3d6715a03868bf4f3fff0dc21867f36e Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 17:01:06 -0600 Subject: [PATCH 09/23] tests: Update validation message in sign_pdf test - Changed the `pytest.raises` match regex to validate against the updated error message: "Both pfx and passphrase". - Ensures consistency with the revised validation logic. Assisted-by: Codex --- tests/test_sign_pdf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 51b9a2f..677734e 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -200,7 +200,7 @@ def test_sign_pdf_requires_credential_pair( with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=r"pfx.*passphrase"), + pytest.raises(ValidationError, match=r"Both pfx and passphrase"), ): client.sign_pdf( input_file, From 3256e491de1d13b4e625c5e2b8643b62950fcb9a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 17:23:26 -0600 Subject: [PATCH 10/23] tests/live: Sanitize passphrase input in test_live_sign_pdf - Strip whitespace from the passphrase file content to prevent errors. - Updated `client.files.create` to include sanitized passphrase data within the payload. Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 06e271c..6c77b7c 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -48,11 +48,14 @@ def uploaded_passphrase( pdfrest_live_base_url: str, ) -> PdfRestFile: resource = get_test_resource_path("signing_passphrase.txt") + sanitized_passphrase = resource.read_text(encoding="utf-8").strip() with PdfRestClient( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client: - return client.files.create_from_paths([resource])[0] + return client.files.create( + [(resource.name, sanitized_passphrase.encode("utf-8"), "text/plain")] + )[0] @pytest.fixture(scope="module") From 9884f99fa1e02aaa4f9c54c36d914e2968aa3138 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 18:03:40 -0600 Subject: [PATCH 11/23] models: Update error message to reference combined MIME types - Changed the error message to correctly list `combined_allowed_mime_types` instead of `allowed_mime_types` for improved clarity and accuracy during validation. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 33a6e3f..3f441ba 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -248,7 +248,10 @@ def allowed_mime_types_validator( _ = allowed_mime_types_validator(item) return value if value.type not in combined_allowed_mime_types: - msg = error_msg or f"The file type must be one of: {allowed_mime_types}" + msg = ( + error_msg + or f"The file type must be one of: {combined_allowed_mime_types}" + ) raise ValueError(msg) return value From 4553d72cc04d176665c37f898124548118953763 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 18:03:57 -0600 Subject: [PATCH 12/23] tests/models: Support x509-ca-cert MIME type for DER credentials - Added a test to validate the handling of `application/x-x509-ca-cert` MIME type for DER certificate and private key uploads in `PdfSignPayload`. - Updated `_internal.py` to allow `application/x-x509-ca-cert` and other relevant MIME types for DER credentials to maintain compatibility with provider/browser file type detection. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 5 +++++ tests/test_sign_pdf.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 3f441ba..fe52da3 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1554,6 +1554,8 @@ class PdfSignPayload(BaseModel): ), BeforeValidator(_ensure_list), BeforeValidator( + # DER cert/key uploads are frequently tagged as x509-ca-cert (or octet-stream + # in some environments), so we intentionally keep this allowlist broad. _allowed_mime_types( "application/pkix-cert", "application/x-x509-ca-cert", @@ -1575,7 +1577,10 @@ class PdfSignPayload(BaseModel): ), BeforeValidator(_ensure_list), BeforeValidator( + # Keep parity with provider/browser MIME detection for DER private keys. _allowed_mime_types( + "application/pkix-cert", + "application/x-x509-ca-cert", "application/pkcs8", "application/x-pem-file", "application/octet-stream", diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 677734e..95ae27a 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -338,3 +338,41 @@ def test_sign_payload_allows_existing_without_location() -> None: } ) assert payload.signature_configuration.type == "existing" + + +def test_sign_payload_accepts_x509_ca_cert_mime_for_der_credentials() -> None: + input_pdf = make_pdf_file(str(PdfRestFileID.generate())) + certificate_file = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), + "certificate.der", + "application/x-x509-ca-cert", + ) + ) + private_key_file = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), + "private_key.der", + "application/x-x509-ca-cert", + ) + ) + + payload = PdfSignPayload.model_validate( + { + "files": [input_pdf], + "signature_configuration": { + "type": "new", + "name": "sig", + "location": make_signature_location(), + }, + "credentials": { + "certificate": certificate_file, + "private_key": private_key_file, + }, + } + ) + + assert payload.certificate is not None + assert payload.private_key is not None + assert payload.certificate[0].type == "application/x-x509-ca-cert" + assert payload.private_key[0].type == "application/x-x509-ca-cert" From 26302264e9deab981ae51ed3516895a369a6e563 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 18:08:31 -0600 Subject: [PATCH 13/23] models: Split PdfSignatureConfiguration into separate types - Introduced `PdfNewSignatureConfiguration` and `PdfExistingSignatureConfiguration` to replace the unified `PdfSignatureConfiguration` type. - `PdfSignatureConfiguration` is now a union of `PdfNewSignatureConfiguration` and `PdfExistingSignatureConfiguration`, allowing stricter type validation and clarity. - Ensures each signature type has its respective required and optional fields. Assisted-by: Codex --- src/pdfrest/types/public.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 54fa169..249630f 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -188,14 +188,27 @@ class PdfSignatureDisplay(TypedDict, total=False): reason: str -class PdfSignatureConfiguration(TypedDict, total=False): - type: Required[Literal["new", "existing"]] +class PdfNewSignatureConfiguration(TypedDict, total=False): + type: Required[Literal["new"]] location: Required[PdfSignatureLocation] name: str logo_opacity: float display: PdfSignatureDisplay +class PdfExistingSignatureConfiguration(TypedDict, total=False): + type: Required[Literal["existing"]] + location: PdfSignatureLocation + name: str + logo_opacity: float + display: PdfSignatureDisplay + + +PdfSignatureConfiguration = ( + PdfNewSignatureConfiguration | PdfExistingSignatureConfiguration +) + + class PdfPfxCredentials(TypedDict): pfx: Required[PdfRestFile] passphrase: Required[PdfRestFile] From 08d6293d155e67bfcd011ca1e56350c2df6ef772 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Mon, 16 Feb 2026 18:12:30 -0600 Subject: [PATCH 14/23] tests: Add and enhance tests for signing PDF with invalid configurations - Updated `test_live_sign_pdf_invalid_signature_configuration` to improve validation by matching against specific error messages. - Added `test_live_async_sign_pdf_invalid_signature_configuration` to test async behavior for invalid signature configurations. - Introduced `test_sign_pdf_request_customization` to validate request headers, query params, payload, and timeout customization during PDF signing. - Ensured stricter validation of malformed JSON in signature configuration. Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 35 +++++++++++++++- tests/test_sign_pdf.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 6c77b7c..171334c 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -216,7 +216,10 @@ def test_live_sign_pdf_invalid_signature_configuration( api_key=pdfrest_api_key, base_url=pdfrest_live_base_url, ) as client, - pytest.raises(PdfRestApiError), + pytest.raises( + PdfRestApiError, + match=r"JSON data provided is not properly formatted", + ), ): client.sign_pdf( uploaded_pdf_for_signing, @@ -230,3 +233,33 @@ def test_live_sign_pdf_invalid_signature_configuration( }, extra_body={"signature_configuration": "not-json"}, ) + + +@pytest.mark.asyncio +async def test_live_async_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: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, + match=r"JSON data provided is not properly formatted", + ): + await 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/test_sign_pdf.py b/tests/test_sign_pdf.py index 95ae27a..5f6dbc7 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -235,6 +235,74 @@ def test_sign_pdf_requires_location_for_new_signature_type( ) +def test_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"] == "sync" + assert request.headers["X-Debug"] == "sync-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"] == "sync-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"] == "sync" + assert request.headers["X-Debug"] == "sync-sign" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "sync-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={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + output="sync-signed", + extra_query={"trace": "sync"}, + extra_headers={"X-Debug": "sync-sign"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "sync-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) + + @pytest.mark.asyncio async def test_async_sign_pdf_request_customization( monkeypatch: pytest.MonkeyPatch, From 05261b9ec6116d88e747ffac68b7cd37186976c6 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Tue, 17 Feb 2026 12:15:38 -0600 Subject: [PATCH 15/23] models/tests: Transition validators and add MIME type support - Changed `BeforeValidator` to `AfterValidator` in `_internal.py` to improve validation reliability and sequencing. - Added support for `application/pem-certificate-chain` MIME type for PEM credentials to widen compatibility with certificate formats. - Updated tests to ensure validation handles `pem-certificate-chain` MIME type for both certificates and private keys. - Introduced `test_sign_payload_accepts_logo_tuple_sequence` to verify the logo field supports tuple sequences in payloads. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 12 +++--- tests/test_sign_pdf.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index fe52da3..be24ebc 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1514,7 +1514,7 @@ class PdfSignPayload(BaseModel): serialization_alias="pfx_credential_id", ), BeforeValidator(_ensure_list), - BeforeValidator( + AfterValidator( _allowed_mime_types( "application/x-pkcs12", "application/pkcs12", @@ -1534,7 +1534,7 @@ class PdfSignPayload(BaseModel): serialization_alias="pfx_passphrase_id", ), BeforeValidator(_ensure_list), - BeforeValidator( + AfterValidator( _allowed_mime_types( "text/plain", "application/octet-stream", @@ -1553,13 +1553,14 @@ class PdfSignPayload(BaseModel): serialization_alias="certificate_id", ), BeforeValidator(_ensure_list), - BeforeValidator( + AfterValidator( # DER cert/key uploads are frequently tagged as x509-ca-cert (or octet-stream # in some environments), so we intentionally keep this allowlist broad. _allowed_mime_types( "application/pkix-cert", "application/x-x509-ca-cert", "application/x-pem-file", + "application/pem-certificate-chain", "application/octet-stream", error_msg="Certificate must be a .pem or .der file", ) @@ -1576,13 +1577,14 @@ class PdfSignPayload(BaseModel): serialization_alias="private_key_id", ), BeforeValidator(_ensure_list), - BeforeValidator( + AfterValidator( # Keep parity with provider/browser MIME detection for DER private keys. _allowed_mime_types( "application/pkix-cert", "application/x-x509-ca-cert", "application/pkcs8", "application/x-pem-file", + "application/pem-certificate-chain", "application/octet-stream", error_msg="Private key must be a .pem or .der file", ) @@ -1599,7 +1601,7 @@ class PdfSignPayload(BaseModel): serialization_alias="logo_id", ), BeforeValidator(_ensure_list), - BeforeValidator( + AfterValidator( _allowed_mime_types( "image/jpeg", "image/png", diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 5f6dbc7..3d31bc1 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -444,3 +444,68 @@ def test_sign_payload_accepts_x509_ca_cert_mime_for_der_credentials() -> None: assert payload.private_key is not None assert payload.certificate[0].type == "application/x-x509-ca-cert" assert payload.private_key[0].type == "application/x-x509-ca-cert" + + +def test_sign_payload_accepts_pem_certificate_chain_mime_for_pem_credentials() -> None: + input_pdf = make_pdf_file(str(PdfRestFileID.generate())) + certificate_file = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), + "certificate.pem", + "application/pem-certificate-chain", + ) + ) + private_key_file = PdfRestFile.model_validate( + build_file_info_payload( + str(PdfRestFileID.generate()), + "private_key.pem", + "application/pem-certificate-chain", + ) + ) + + payload = PdfSignPayload.model_validate( + { + "files": [input_pdf], + "signature_configuration": { + "type": "new", + "name": "sig", + "location": make_signature_location(), + }, + "credentials": { + "certificate": certificate_file, + "private_key": private_key_file, + }, + } + ) + + assert payload.certificate is not None + assert payload.private_key is not None + assert payload.certificate[0].type == "application/pem-certificate-chain" + assert payload.private_key[0].type == "application/pem-certificate-chain" + + +def test_sign_payload_accepts_logo_tuple_sequence() -> None: + input_pdf = make_pdf_file(str(PdfRestFileID.generate())) + 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())) + + payload = PdfSignPayload.model_validate( + { + "files": [input_pdf], + "signature_configuration": { + "type": "new", + "name": "sig", + "location": make_signature_location(), + }, + "credentials": { + "certificate": certificate_file, + "private_key": private_key_file, + }, + "logo": (logo_file,), + } + ) + + assert payload.logo is not None + assert len(payload.logo) == 1 + assert payload.logo[0].id == logo_file.id From 4cbafeed344c3a2dbd594eb8abc699758d364d77 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 15:34:29 -0600 Subject: [PATCH 16/23] models: Restrict PdfSignaturePoint coordinates to float type - Updated `_PdfSignaturePointModel` and `PdfSignaturePoint` to enforce `x` and `y` as `float` instead of accepting `str | int | float`. - Ensures stricter type validation for signature positioning. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 4 ++-- src/pdfrest/types/public.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index be24ebc..39e148a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -988,8 +988,8 @@ class PdfPresetRedactionModel(BaseModel): class _PdfSignaturePointModel(BaseModel): - x: str | int | float - y: str | int | float + x: float + y: float class _PdfSignatureLocationModel(BaseModel): diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 249630f..742d3fd 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -169,8 +169,8 @@ class PdfMergeSource(TypedDict, total=False): class PdfSignaturePoint(TypedDict): - x: str | int | float - y: str | int | float + x: float + y: float class PdfSignatureLocation(TypedDict): From 713f2ca7f24fc07728e222b91d20135fae243d96 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 17:14:30 -0600 Subject: [PATCH 17/23] docs: Clarify payload modeling guidelines in AGENTS.md - Updated payload modeling section to emphasize mirroring pdfRest's request field layout for consistency. - Added recommendations to use `@model_validator(mode="before")` for mapping user-friendly input onto pdfRest fields. - Advised against using `@model_serializer` on payload models to maintain separation of wire formatting in field serializers. Assisted-by: Codex --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7f627a1..9da7312 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ helpers should pass sequences through without converting to raw IDs manually. - When a payload accepts uploaded content, validate MIME types via `_allowed_mime_types` to surface clear errors before making the request. +- Payload models should mirror pdfRest's request layout field-for-field. Do not + use `@model_serializer` on payload models. If callers need a friendlier input + shape, use `@model_validator(mode="before")` to map inputs onto the existing + pdfRest fields, and keep any wire formatting in field serializers. - When an endpoint expects JSON-encoded structures (e.g., arrays of redaction rules), expose typed arguments (TypedDicts, Literals, etc.) via `pdfrest.types` and let the payload serializer produce the JSON string for the From 07e831b9dac16c2c394008bd622ee210202b28dd Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 17:24:45 -0600 Subject: [PATCH 18/23] tests/live: Enhance PDF signing tests with additional cases - Added tests for new and existing signature type literals to ensure consistent behavior across sync and async API clients. - Introduced logo opacity bounds tests to validate edge case constraints for this field. - Implemented invalid configuration tests for unsupported signature types and logo opacity values. - Refactored reusable components: - `SIGNATURE_TYPES`, `LOGO_OPACITY_BOUNDS`, and `INVALID_LOGO_OPACITY_VALUES` for parametrized test cases. - `_to_json_string` utility function for consistent JSON encoding. Assisted-by: Codex --- tests/live/test_live_sign_pdf.py | 344 +++++++++++++++++++++++++++++++ tests/test_sign_pdf.py | 253 +++++++++++++++++++++++ 2 files changed, 597 insertions(+) diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py index 171334c..b852d89 100644 --- a/tests/live/test_live_sign_pdf.py +++ b/tests/live/test_live_sign_pdf.py @@ -1,12 +1,32 @@ from __future__ import annotations import pytest +from pydantic_core import to_json from pdfrest import AsyncPdfRestClient, PdfRestApiError, PdfRestClient from pdfrest.models import PdfRestFile from ..resources import get_test_resource_path +SIGNATURE_TYPES = ( + pytest.param("new", id="new"), + pytest.param("existing", id="existing"), +) + +LOGO_OPACITY_BOUNDS = ( + pytest.param((0.01, "min"), id="min"), + pytest.param((1.0, "max"), id="max"), +) + +INVALID_LOGO_OPACITY_VALUES = ( + pytest.param(0.0, id="zero"), + pytest.param(1.1, id="above-max"), +) + + +def _to_json_string(value: object) -> str: + return to_json(value).decode("utf-8") + def make_signature_location() -> dict[str, dict[str, int] | int]: return { @@ -169,6 +189,97 @@ def test_live_sign_pdf_with_existing_signature_field( assert str(first_response.output_file.id) in existing_response.input_ids +@pytest.mark.parametrize("signature_type", SIGNATURE_TYPES) +def test_live_sign_pdf_signature_type_literals( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, + signature_type: str, +) -> None: + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + if signature_type == "new": + response = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": "live-sign-literal-new", + "location": make_signature_location(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-sign-literal-new", + ) + else: + signature_name = "live-sign-literal-existing" + prepared = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": signature_name, + "location": make_signature_location(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-sign-literal-existing-prep", + ) + response = client.sign_pdf( + prepared.output_file, + signature_configuration={"type": "existing", "name": signature_name}, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-sign-literal-existing", + ) + + assert response.output_file.type == "application/pdf" + assert str(response.output_file.id) in str(response.output_file.url) + + +@pytest.mark.parametrize("logo_opacity_case", LOGO_OPACITY_BOUNDS) +def test_live_sign_pdf_logo_opacity_bounds( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_certificate: PdfRestFile, + uploaded_private_key: PdfRestFile, + uploaded_logo: PdfRestFile, + logo_opacity_case: tuple[float, str], +) -> None: + logo_opacity, case_label = logo_opacity_case + 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={ + "type": "new", + "name": f"live-logo-opacity-{case_label}", + "logo_opacity": logo_opacity, + "location": make_signature_location(), + }, + credentials={ + "certificate": uploaded_certificate, + "private_key": uploaded_private_key, + }, + logo=uploaded_logo, + output=f"live-logo-opacity-{case_label}", + ) + + assert response.output_file.type == "application/pdf" + assert uploaded_logo.id in response.input_ids + + @pytest.mark.asyncio async def test_live_async_sign_pdf_with_certificate( pdfrest_api_key: str, @@ -204,6 +315,99 @@ async def test_live_async_sign_pdf_with_certificate( assert uploaded_logo.id in response.input_ids +@pytest.mark.asyncio +@pytest.mark.parametrize("signature_type", SIGNATURE_TYPES) +async def test_live_async_sign_pdf_signature_type_literals( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, + signature_type: str, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + if signature_type == "new": + response = await client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": "live-async-sign-literal-new", + "location": make_signature_location(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-async-sign-literal-new", + ) + else: + signature_name = "live-async-sign-literal-existing" + prepared = await client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration={ + "type": "new", + "name": signature_name, + "location": make_signature_location(), + }, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-async-sign-literal-existing-prep", + ) + response = await client.sign_pdf( + prepared.output_file, + signature_configuration={"type": "existing", "name": signature_name}, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-async-sign-literal-existing", + ) + + assert response.output_file.type == "application/pdf" + assert str(response.output_file.id) in str(response.output_file.url) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("logo_opacity_case", LOGO_OPACITY_BOUNDS) +async def test_live_async_sign_pdf_logo_opacity_bounds( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_certificate: PdfRestFile, + uploaded_private_key: PdfRestFile, + uploaded_logo: PdfRestFile, + logo_opacity_case: tuple[float, str], +) -> None: + logo_opacity, case_label = logo_opacity_case + 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={ + "type": "new", + "name": f"live-async-logo-opacity-{case_label}", + "logo_opacity": logo_opacity, + "location": make_signature_location(), + }, + credentials={ + "certificate": uploaded_certificate, + "private_key": uploaded_private_key, + }, + logo=uploaded_logo, + output=f"live-async-logo-opacity-{case_label}", + ) + + assert response.output_file.type == "application/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, @@ -235,6 +439,76 @@ def test_live_sign_pdf_invalid_signature_configuration( ) +def test_live_sign_pdf_invalid_signature_type_literal( + 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, match=r"(?i)signature|type|formatted"), + ): + 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": _to_json_string( + {"type": "unexpected", "location": make_signature_location()} + ) + }, + ) + + +@pytest.mark.parametrize("invalid_logo_opacity", INVALID_LOGO_OPACITY_VALUES) +def test_live_sign_pdf_invalid_logo_opacity( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, + invalid_logo_opacity: float, +) -> None: + with ( + PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client, + pytest.raises(PdfRestApiError, match=r"(?i)logo_opacity|opacity|formatted"), + ): + 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": _to_json_string( + { + "type": "new", + "location": make_signature_location(), + "logo_opacity": invalid_logo_opacity, + } + ) + }, + ) + + @pytest.mark.asyncio async def test_live_async_sign_pdf_invalid_signature_configuration( pdfrest_api_key: str, @@ -263,3 +537,73 @@ async def test_live_async_sign_pdf_invalid_signature_configuration( }, extra_body={"signature_configuration": "not-json"}, ) + + +@pytest.mark.asyncio +async def test_live_async_sign_pdf_invalid_signature_type_literal( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises(PdfRestApiError, match=r"(?i)signature|type|formatted"): + await 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": _to_json_string( + {"type": "unexpected", "location": make_signature_location()} + ) + }, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_logo_opacity", INVALID_LOGO_OPACITY_VALUES) +async def test_live_async_sign_pdf_invalid_logo_opacity( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, + invalid_logo_opacity: float, +) -> None: + async with AsyncPdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + with pytest.raises( + PdfRestApiError, match=r"(?i)logo_opacity|opacity|formatted" + ): + await 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": _to_json_string( + { + "type": "new", + "location": make_signature_location(), + "logo_opacity": invalid_logo_opacity, + } + ) + }, + ) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 3d31bc1..8b0e402 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -56,6 +56,16 @@ def make_signature_location() -> dict[str, dict[str, int] | int]: } +def make_signature_configuration(signature_type: str) -> dict[str, object]: + if signature_type == "new": + return { + "type": "new", + "name": "esignature", + "location": make_signature_location(), + } + return {"type": "existing", "name": "esignature"} + + 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)) @@ -212,6 +222,27 @@ def test_sign_pdf_requires_credential_pair( ) +def test_sign_pdf_rejects_non_mapping_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(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"credentials must be a mapping"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials=["not-a-mapping"], # type: ignore[arg-type] + ) + + def test_sign_pdf_requires_location_for_new_signature_type( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -235,6 +266,168 @@ def test_sign_pdf_requires_location_for_new_signature_type( ) +@pytest.mark.asyncio +async def test_async_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=r"Both pfx and passphrase"): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file}, + ) + + +@pytest.mark.asyncio +async def test_async_sign_pdf_rejects_non_mapping_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=r"credentials must be a mapping"): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials=["not-a-mapping"], # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_async_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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, + match=r"Missing location information for a new digital signature field", + ): + await client.sign_pdf( + input_file, + signature_configuration={"type": "new"}, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + +@pytest.mark.parametrize( + "signature_type", + [ + pytest.param("new", id="new"), + pytest.param("existing", id="existing"), + ], +) +def test_sign_pdf_signature_type_literal_matrix( + monkeypatch: pytest.MonkeyPatch, + signature_type: str, +) -> 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())) + output_id = str(PdfRestFileID.generate()) + + 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")) + signature_payload = json.loads(payload["signature_configuration"]) + assert signature_payload["type"] == signature_type + return httpx.Response( + 200, + json={"inputId": [input_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, + f"literal-{signature_type}.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=make_signature_configuration(signature_type), + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + assert response.output_file.name == f"literal-{signature_type}.pdf" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "signature_type", + [ + pytest.param("new", id="new"), + pytest.param("existing", id="existing"), + ], +) +async def test_async_sign_pdf_signature_type_literal_matrix( + monkeypatch: pytest.MonkeyPatch, + signature_type: str, +) -> 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())) + output_id = str(PdfRestFileID.generate()) + + 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")) + signature_payload = json.loads(payload["signature_configuration"]) + assert signature_payload["type"] == signature_type + return httpx.Response( + 200, + json={"inputId": [input_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, + f"literal-async-{signature_type}.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=make_signature_configuration(signature_type), + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + assert response.output_file.name == f"literal-async-{signature_type}.pdf" + + def test_sign_pdf_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -509,3 +702,63 @@ def test_sign_payload_accepts_logo_tuple_sequence() -> None: assert payload.logo is not None assert len(payload.logo) == 1 assert payload.logo[0].id == logo_file.id + + +@pytest.mark.parametrize( + "logo_opacity", + [ + pytest.param(0.0, id="min"), + pytest.param(1.0, id="max"), + ], +) +def test_sign_payload_accepts_logo_opacity_bounds(logo_opacity: float) -> None: + input_pdf = make_pdf_file(str(PdfRestFileID.generate())) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + + payload = PdfSignPayload.model_validate( + { + "files": [input_pdf], + "signature_configuration": { + "type": "new", + "name": "sig", + "logo_opacity": logo_opacity, + "location": make_signature_location(), + }, + "credentials": {"pfx": pfx_file, "passphrase": passphrase_file}, + } + ) + + assert payload.signature_configuration.logo_opacity == pytest.approx(logo_opacity) + + +@pytest.mark.parametrize( + "invalid_logo_opacity", + [ + pytest.param(-0.01, id="below-min"), + pytest.param(1.01, id="above-max"), + ], +) +def test_sign_payload_rejects_logo_opacity_out_of_bounds( + invalid_logo_opacity: float, +) -> None: + input_pdf = make_pdf_file(str(PdfRestFileID.generate())) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + + with pytest.raises( + ValidationError, + match=r"greater than or equal to 0|less than or equal to 1", + ): + PdfSignPayload.model_validate( + { + "files": [input_pdf], + "signature_configuration": { + "type": "new", + "name": "sig", + "logo_opacity": invalid_logo_opacity, + "location": make_signature_location(), + }, + "credentials": {"pfx": pfx_file, "passphrase": passphrase_file}, + } + ) From 350e08f153eeaeadd65c5d4b4822a2108b12676a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 17:25:05 -0600 Subject: [PATCH 19/23] models: Change TypeError to ValueError in validation logic - Updated `_internal.py` to raise `ValueError` instead of `TypeError` for credential validation errors, ensuring compliance with Pydantic error handling best practices. - Documented this change in AGENTS.md to guide contributors on proper error types for validators. Assisted-by: Codex --- AGENTS.md | 3 +++ src/pdfrest/models/_internal.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9da7312..cf46ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,9 @@ - Avoid `@field_validator` on payload models. Prefer existing `BeforeValidator` helpers (e.g., `_allowed_mime_types`) so validation remains declarative and consistent across schemas. +- In Pydantic validators, raise `ValueError`/`AssertionError` (or + `PydanticCustomError` when needed), not `TypeError`, so callers consistently + receive `ValidationError` surfaces. - Keep user-facing `PdfRestClient` and `AsyncPdfRestClient` endpoint helpers thin: they should primarily assemble payload dicts and delegate validation to payload models (`model_validate`). Avoid duplicating payload validation in diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 39e148a..0f2c01a 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1633,7 +1633,7 @@ def _normalize_credentials(cls, data: Any) -> Any: "credentials must be a mapping with either pfx/passphrase or " "certificate/private_key." ) - raise TypeError(msg) + raise ValueError(msg) # noqa: TRY004 normalized: dict[str, Any] = {str(key): value for key, value in payload.items()} credential_map = cast(Mapping[object, Any], credentials) From a699dea7fe4ff979dc8a3b28633d08086195a023 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 17:34:04 -0600 Subject: [PATCH 20/23] models/tests: Enforce logo_opacity > 0 and update validation tests - Updated `_PdfSignatureConfigurationModel` to set `logo_opacity` lower bound as strictly greater than 0 (`gt=0`) instead of greater than or equal to 0 (`ge=0`). - Modified `test_sign_pdf.py` to reflect the updated constraint: - Adjusted parametrized tests to replace 0.0 with 0.01 for minimum valid opacity. - Added test case for 0.0 as an invalid opacity value. - Updated validation error match regex to account for the new rule. Assisted-by: Codex --- src/pdfrest/models/_internal.py | 2 +- tests/test_sign_pdf.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 0f2c01a..dec907e 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -1010,7 +1010,7 @@ class _PdfSignatureDisplayModel(BaseModel): class _PdfSignatureConfigurationModel(BaseModel): type: Literal["new", "existing"] name: str | None = None - logo_opacity: Annotated[float | None, Field(ge=0, le=1, default=None)] = None + logo_opacity: Annotated[float | None, Field(gt=0, le=1, default=None)] = None location: _PdfSignatureLocationModel | None = None display: _PdfSignatureDisplayModel | None = None diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 8b0e402..888368f 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -707,7 +707,7 @@ def test_sign_payload_accepts_logo_tuple_sequence() -> None: @pytest.mark.parametrize( "logo_opacity", [ - pytest.param(0.0, id="min"), + pytest.param(0.01, id="min"), pytest.param(1.0, id="max"), ], ) @@ -735,6 +735,7 @@ def test_sign_payload_accepts_logo_opacity_bounds(logo_opacity: float) -> None: @pytest.mark.parametrize( "invalid_logo_opacity", [ + pytest.param(0.0, id="zero"), pytest.param(-0.01, id="below-min"), pytest.param(1.01, id="above-max"), ], @@ -748,7 +749,7 @@ def test_sign_payload_rejects_logo_opacity_out_of_bounds( with pytest.raises( ValidationError, - match=r"greater than or equal to 0|less than or equal to 1", + match=r"greater than 0|less than or equal to 1", ): PdfSignPayload.model_validate( { From d8bd6052328fbc0cde5d817af3dd1b8beb782be0 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 18:00:19 -0600 Subject: [PATCH 21/23] tests: Add validation for handling multiple input, logo, or credential files - Introduced validation tests in `test_sign_pdf.py` to ensure rejection of multiple input PDFs, logos, and credential files during PDF signing. - Added corresponding async validation tests for enhanced API behavior coverage. - Verified HTTP request counts (`POST` and `GET`) in sync and async test cases. Assisted-by: Codex --- tests/test_sign_pdf.py | 173 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index 888368f..cc4f200 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -72,6 +72,7 @@ def test_sign_pdf_with_pfx_credentials(monkeypatch: pytest.MonkeyPatch) -> None: pfx_file = make_pfx_file(str(PdfRestFileID.generate())) passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) output_id = str(PdfRestFileID.generate()) + seen = {"post": 0, "get": 0} signature_configuration = { "type": "new", @@ -90,6 +91,7 @@ def test_sign_pdf_with_pfx_credentials(monkeypatch: pytest.MonkeyPatch) -> None: def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/signed-pdf": + seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) assert payload == payload_dump return httpx.Response( @@ -100,6 +102,7 @@ def handler(request: httpx.Request) -> httpx.Response: }, ) if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 assert request.url.params["format"] == "info" return httpx.Response( 200, @@ -124,6 +127,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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} + assert seen == {"post": 1, "get": 1} def test_sign_pdf_with_certificate_credentials_and_logo( @@ -135,6 +139,7 @@ def test_sign_pdf_with_certificate_credentials_and_logo( private_key_file = make_private_key_file(str(PdfRestFileID.generate())) logo_file = make_logo_file(str(PdfRestFileID.generate())) output_id = str(PdfRestFileID.generate()) + seen = {"post": 0, "get": 0} signature_configuration = { "type": "new", @@ -159,6 +164,7 @@ def test_sign_pdf_with_certificate_credentials_and_logo( def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/signed-pdf": + seen["post"] += 1 payload = json.loads(request.content.decode("utf-8")) assert payload == payload_dump return httpx.Response( @@ -174,6 +180,7 @@ def handler(request: httpx.Request) -> httpx.Response: }, ) if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 return httpx.Response( 200, json=build_file_info_payload( @@ -198,6 +205,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(response, PdfRestFileBasedResponse) assert response.output_file.name == "signed.pdf" assert logo_file.id in response.input_ids + assert seen == {"post": 1, "get": 1} def test_sign_pdf_requires_credential_pair( @@ -266,6 +274,86 @@ def test_sign_pdf_requires_location_for_new_signature_type( ) +def test_sign_pdf_rejects_multiple_input_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file_a = make_pdf_file(PdfRestFileID.generate()) + input_file_b = 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"files|at most 1 item"), + ): + client.sign_pdf( + [input_file_a, input_file_b], + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + +def test_sign_pdf_rejects_multiple_logo_files( + 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())) + logo_file_a = make_logo_file(str(PdfRestFileID.generate())) + logo_file_b = make_logo_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"logo|at most 1 item"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + logo=[logo_file_a, logo_file_b], + ) + + +def test_sign_pdf_rejects_multiple_pfx_credential_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file_a = make_pfx_file(str(PdfRestFileID.generate())) + pfx_file_b = 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"pfx|at most 1 item"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "pfx": [pfx_file_a, pfx_file_b], # type: ignore[list-item] + "passphrase": passphrase_file, + }, + ) + + @pytest.mark.asyncio async def test_async_sign_pdf_requires_credential_pair( monkeypatch: pytest.MonkeyPatch, @@ -329,6 +417,83 @@ async def test_async_sign_pdf_requires_location_for_new_signature_type( ) +@pytest.mark.asyncio +async def test_async_sign_pdf_rejects_multiple_input_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file_a = make_pdf_file(PdfRestFileID.generate()) + input_file_b = 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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=r"files|at most 1 item"): + await client.sign_pdf( + [input_file_a, input_file_b], + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + +@pytest.mark.asyncio +async def test_async_sign_pdf_rejects_multiple_logo_files( + 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())) + logo_file_a = make_logo_file(str(PdfRestFileID.generate())) + logo_file_b = make_logo_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=r"logo|at most 1 item"): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + logo=[logo_file_a, logo_file_b], + ) + + +@pytest.mark.asyncio +async def test_async_sign_pdf_rejects_multiple_pfx_credential_files( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file_a = make_pfx_file(str(PdfRestFileID.generate())) + pfx_file_b = make_pfx_file(str(PdfRestFileID.generate())) + passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises(ValidationError, match=r"pfx|at most 1 item"): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "pfx": [pfx_file_a, pfx_file_b], # type: ignore[list-item] + "passphrase": passphrase_file, + }, + ) + + @pytest.mark.parametrize( "signature_type", [ @@ -437,9 +602,11 @@ def test_sign_pdf_request_customization( private_key_file = make_private_key_file(str(PdfRestFileID.generate())) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} + seen = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/signed-pdf": + seen["post"] += 1 assert request.url.params["trace"] == "sync" assert request.headers["X-Debug"] == "sync-sign" captured_timeout["value"] = request.extensions.get("timeout") @@ -454,6 +621,7 @@ def handler(request: httpx.Request) -> httpx.Response: json={"inputId": [input_file.id], "outputId": [output_id]}, ) if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 assert request.url.params["trace"] == "sync" assert request.headers["X-Debug"] == "sync-sign" return httpx.Response( @@ -494,6 +662,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert all(pytest.approx(0.5) == value for value in timeout_value.values()) else: assert timeout_value == pytest.approx(0.5) + assert seen == {"post": 1, "get": 1} @pytest.mark.asyncio @@ -506,9 +675,11 @@ async def test_async_sign_pdf_request_customization( private_key_file = make_private_key_file(str(PdfRestFileID.generate())) output_id = str(PdfRestFileID.generate()) captured_timeout: dict[str, float | dict[str, float] | None] = {} + seen = {"post": 0, "get": 0} def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST" and request.url.path == "/signed-pdf": + seen["post"] += 1 assert request.url.params["trace"] == "async" assert request.headers["X-Debug"] == "async-sign" captured_timeout["value"] = request.extensions.get("timeout") @@ -523,6 +694,7 @@ def handler(request: httpx.Request) -> httpx.Response: json={"inputId": [input_file.id], "outputId": [output_id]}, ) if request.method == "GET" and request.url.path == f"/resource/{output_id}": + seen["get"] += 1 assert request.url.params["trace"] == "async" assert request.headers["X-Debug"] == "async-sign" return httpx.Response( @@ -566,6 +738,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert all(pytest.approx(0.5) == value for value in timeout_value.values()) else: assert timeout_value == pytest.approx(0.5) + assert seen == {"post": 1, "get": 1} def test_sign_payload_requires_location_when_type_new() -> None: From 8a09b43620930b184e93734259cf55950cef3e0a Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Wed, 18 Feb 2026 18:14:50 -0600 Subject: [PATCH 22/23] tests: Add parametric tests for multiple credential file rejection - Refactored `test_sign_pdf_rejects_multiple_credential_files` and `test_async_sign_pdf_rejects_multiple_credential_files` to use `@pytest.mark.parametrize`. - Introduced `MULTI_FILE_CREDENTIAL_CASES` for test parameterization, covering different credential file types and combinations. - Enhanced validation error matching for clearer and more precise diagnostics. Assisted-by: Codex --- tests/test_sign_pdf.py | 97 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index cc4f200..a2ea338 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections.abc import Callable import httpx import pytest @@ -66,6 +67,32 @@ def make_signature_configuration(signature_type: str) -> dict[str, object]: return {"type": "existing", "name": "esignature"} +MULTI_FILE_CREDENTIAL_CASES = ( + pytest.param("pfx", "passphrase", make_pfx_file, make_passphrase_file, id="pfx"), + pytest.param( + "passphrase", + "pfx", + make_passphrase_file, + make_pfx_file, + id="passphrase", + ), + pytest.param( + "certificate", + "private_key", + make_certificate_file, + make_private_key_file, + id="certificate", + ), + pytest.param( + "private_key", + "certificate", + make_private_key_file, + make_certificate_file, + id="private-key", + ), +) + + 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)) @@ -286,7 +313,10 @@ def test_sign_pdf_rejects_multiple_input_files( with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=r"files|at most 1 item"), + pytest.raises( + ValidationError, + match=r"files\n\s+List should have at most 1 item after validation", + ), ): client.sign_pdf( [input_file_a, input_file_b], @@ -311,7 +341,10 @@ def test_sign_pdf_rejects_multiple_logo_files( with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=r"logo|at most 1 item"), + pytest.raises( + ValidationError, + match=r"logo\n\s+List should have at most 1 item after validation", + ), ): client.sign_pdf( input_file, @@ -327,19 +360,30 @@ def test_sign_pdf_rejects_multiple_logo_files( ) -def test_sign_pdf_rejects_multiple_pfx_credential_files( +@pytest.mark.parametrize( + ("multi_field", "single_field", "multi_factory", "single_factory"), + MULTI_FILE_CREDENTIAL_CASES, +) +def test_sign_pdf_rejects_multiple_credential_files( monkeypatch: pytest.MonkeyPatch, + multi_field: str, + single_field: str, + multi_factory: Callable[[str], PdfRestFile], + single_factory: Callable[[str], PdfRestFile], ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate()) - pfx_file_a = make_pfx_file(str(PdfRestFileID.generate())) - pfx_file_b = make_pfx_file(str(PdfRestFileID.generate())) - passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + multi_file_a = multi_factory(str(PdfRestFileID.generate())) + multi_file_b = multi_factory(str(PdfRestFileID.generate())) + single_file = single_factory(str(PdfRestFileID.generate())) transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + expected_match = ( + rf"{multi_field}\n\s+List should have at most 1 item after validation" + ) with ( PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, - pytest.raises(ValidationError, match=r"pfx|at most 1 item"), + pytest.raises(ValidationError, match=expected_match), ): client.sign_pdf( input_file, @@ -348,8 +392,8 @@ def test_sign_pdf_rejects_multiple_pfx_credential_files( "location": make_signature_location(), }, credentials={ - "pfx": [pfx_file_a, pfx_file_b], # type: ignore[list-item] - "passphrase": passphrase_file, + multi_field: [multi_file_a, multi_file_b], # type: ignore[dict-item] + single_field: single_file, }, ) @@ -429,7 +473,10 @@ async def test_async_sign_pdf_rejects_multiple_input_files( transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: - with pytest.raises(ValidationError, match=r"files|at most 1 item"): + with pytest.raises( + ValidationError, + match=r"files\n\s+List should have at most 1 item after validation", + ): await client.sign_pdf( [input_file_a, input_file_b], signature_configuration={ @@ -453,7 +500,10 @@ async def test_async_sign_pdf_rejects_multiple_logo_files( transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: - with pytest.raises(ValidationError, match=r"logo|at most 1 item"): + with pytest.raises( + ValidationError, + match=r"logo\n\s+List should have at most 1 item after validation", + ): await client.sign_pdf( input_file, signature_configuration={ @@ -469,18 +519,29 @@ async def test_async_sign_pdf_rejects_multiple_logo_files( @pytest.mark.asyncio -async def test_async_sign_pdf_rejects_multiple_pfx_credential_files( +@pytest.mark.parametrize( + ("multi_field", "single_field", "multi_factory", "single_factory"), + MULTI_FILE_CREDENTIAL_CASES, +) +async def test_async_sign_pdf_rejects_multiple_credential_files( monkeypatch: pytest.MonkeyPatch, + multi_field: str, + single_field: str, + multi_factory: Callable[[str], PdfRestFile], + single_factory: Callable[[str], PdfRestFile], ) -> None: monkeypatch.delenv("PDFREST_API_KEY", raising=False) input_file = make_pdf_file(PdfRestFileID.generate()) - pfx_file_a = make_pfx_file(str(PdfRestFileID.generate())) - pfx_file_b = make_pfx_file(str(PdfRestFileID.generate())) - passphrase_file = make_passphrase_file(str(PdfRestFileID.generate())) + multi_file_a = multi_factory(str(PdfRestFileID.generate())) + multi_file_b = multi_factory(str(PdfRestFileID.generate())) + single_file = single_factory(str(PdfRestFileID.generate())) transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + expected_match = ( + rf"{multi_field}\n\s+List should have at most 1 item after validation" + ) async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: - with pytest.raises(ValidationError, match=r"pfx|at most 1 item"): + with pytest.raises(ValidationError, match=expected_match): await client.sign_pdf( input_file, signature_configuration={ @@ -488,8 +549,8 @@ async def test_async_sign_pdf_rejects_multiple_pfx_credential_files( "location": make_signature_location(), }, credentials={ - "pfx": [pfx_file_a, pfx_file_b], # type: ignore[list-item] - "passphrase": passphrase_file, + multi_field: [multi_file_a, multi_file_b], # type: ignore[dict-item] + single_field: single_file, }, ) From 6d004b8379eea6635aae2e8c358d5710d8abbe89 Mon Sep 17 00:00:00 2001 From: "Kevin A. Mitchell" Date: Thu, 19 Feb 2026 11:04:04 -0600 Subject: [PATCH 23/23] tests: Add tests for invalid signature type rejection - Added `test_sign_pdf_rejects_invalid_signature_type_literal` to validate rejection of unsupported signature types for synchronous clients. - Added `test_async_sign_pdf_rejects_invalid_signature_type_literal` for async client behavior validation. - Ensures consistent error handling for unsupported configurations. Assisted-by: Codex --- tests/test_sign_pdf.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py index a2ea338..01bbbec 100644 --- a/tests/test_sign_pdf.py +++ b/tests/test_sign_pdf.py @@ -654,6 +654,53 @@ def handler(request: httpx.Request) -> httpx.Response: assert response.output_file.name == f"literal-async-{signature_type}.pdf" +def test_sign_pdf_rejects_invalid_signature_type_literal( + 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="Input should be 'new' or 'existing'"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "unexpected", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + +@pytest.mark.asyncio +async def test_async_sign_pdf_rejects_invalid_signature_type_literal( + 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)) + + async with AsyncPdfRestClient(api_key=ASYNC_API_KEY, transport=transport) as client: + with pytest.raises( + ValidationError, match="Input should be 'new' or 'existing'" + ): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "unexpected", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file, "passphrase": passphrase_file}, + ) + + def test_sign_pdf_request_customization( monkeypatch: pytest.MonkeyPatch, ) -> None: