From 7c83c24da8b82e8d14565518ba5b08e95bfbe36d Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 20 Jan 2026 12:00:28 -0600 Subject: [PATCH 1/3] 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 | 45 ++++++++ 4 files changed, 315 insertions(+) diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index da6dd841..04f85830 100644 --- a/src/pdfrest/client.py +++ b/src/pdfrest/client.py @@ -99,6 +99,7 @@ PdfRedactionPreviewPayload, PdfRestRawFileResponse, PdfRestrictPayload, + PdfSignPayload, PdfSplitPayload, PdfToExcelPayload, PdfToPdfaPayload, @@ -131,6 +132,8 @@ PdfRedactionInstruction, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, PdfXType, PngColorModel, SummaryFormat, @@ -3070,6 +3073,42 @@ def add_attachment_to_pdf( timeout=timeout, ) + def sign_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + signature_configuration: PdfSignatureConfiguration, + credentials: PdfSignatureCredentials, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Digitally sign a PDF using PFX credentials or a certificate/private key.""" + + payload: dict[str, Any] = { + "files": file, + "signature_configuration": signature_configuration, + } + payload.update(credentials) + + if logo is not None: + payload["logo"] = logo + if output is not None: + payload["output"] = output + + return self._post_file_operation( + endpoint="/signed-pdf", + payload=payload, + payload_model=PdfSignPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], @@ -4450,6 +4489,42 @@ async def add_attachment_to_pdf( timeout=timeout, ) + async def sign_pdf( + self, + file: PdfRestFile | Sequence[PdfRestFile], + *, + signature_configuration: PdfSignatureConfiguration, + credentials: PdfSignatureCredentials, + logo: PdfRestFile | Sequence[PdfRestFile] | None = None, + output: str | None = None, + extra_query: Query | None = None, + extra_headers: AnyMapping | None = None, + extra_body: Body | None = None, + timeout: TimeoutTypes | None = None, + ) -> PdfRestFileBasedResponse: + """Digitally sign a PDF using PFX credentials or a certificate/private key.""" + + payload: dict[str, Any] = { + "files": file, + "signature_configuration": signature_configuration, + } + payload.update(credentials) + + if logo is not None: + payload["logo"] = logo + if output is not None: + payload["output"] = output + + return await self._post_file_operation( + endpoint="/signed-pdf", + payload=payload, + payload_model=PdfSignPayload, + extra_query=extra_query, + extra_headers=extra_headers, + extra_body=extra_body, + timeout=timeout, + ) + async def flatten_transparencies( self, file: PdfRestFile | Sequence[PdfRestFile], diff --git a/src/pdfrest/models/_internal.py b/src/pdfrest/models/_internal.py index 997f3937..85a1748c 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -170,6 +170,13 @@ def _serialize_text_objects(value: list[BaseModel]) -> str: return to_json(payload).decode() +def _serialize_signature_configuration( + value: _PdfSignatureConfigurationModel, +) -> str: + payload = value.model_dump(mode="json", exclude_none=True) + return to_json(payload).decode() + + def _allowed_mime_types( allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None ) -> Callable[[Any], Any]: @@ -603,6 +610,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"), @@ -986,6 +1021,158 @@ class PdfFlattenFormsPayload(BaseModel): ] = None +class PdfSignPayload(BaseModel): + """Adapt caller options into a pdfRest-ready sign request payload.""" + + files: Annotated[ + list[PdfRestFile], + Field( + min_length=1, + max_length=1, + validation_alias=AliasChoices("file", "files"), + serialization_alias="id", + ), + BeforeValidator(_ensure_list), + AfterValidator( + _allowed_mime_types("application/pdf", error_msg="Must be a PDF file") + ), + PlainSerializer(_serialize_as_first_file_id), + ] + signature_configuration: Annotated[ + _PdfSignatureConfigurationModel, + Field(serialization_alias="signature_configuration"), + PlainSerializer(_serialize_signature_configuration), + ] + pfx_credential: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("pfx", "pfx_credential"), + serialization_alias="pfx_credential_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/x-pkcs12", + "application/pkcs12", + "application/octet-stream", + error_msg="PFX credentials must be a .pfx or .p12 file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + pfx_passphrase: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("pfx_passphrase", "passphrase"), + serialization_alias="pfx_passphrase_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "text/plain", + "application/octet-stream", + error_msg="PFX passphrase must be a text file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + certificate: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("certificate", "cert"), + serialization_alias="certificate_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/pkix-cert", + "application/x-x509-ca-cert", + "application/x-pem-file", + "application/octet-stream", + error_msg="Certificate must be a .pem or .der file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + private_key: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("private_key", "key"), + serialization_alias="private_key_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "application/pkcs8", + "application/x-pem-file", + "application/octet-stream", + error_msg="Private key must be a .pem or .der file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + logo: Annotated[ + list[PdfRestFile] | None, + Field( + default=None, + min_length=1, + max_length=1, + validation_alias=AliasChoices("logo", "logos"), + serialization_alias="logo_id", + ), + BeforeValidator(_ensure_list), + BeforeValidator( + _allowed_mime_types( + "image/jpeg", + "image/png", + "image/tiff", + "image/bmp", + error_msg="Logo must be an image file", + ) + ), + PlainSerializer(_serialize_as_first_file_id), + ] = None + output: Annotated[ + str | None, + Field(serialization_alias="output", min_length=1, default=None), + AfterValidator(_validate_output_prefix), + ] = None + + @model_validator(mode="after") + def _validate_credentials(self) -> PdfSignPayload: + has_pfx = self.pfx_credential is not None or self.pfx_passphrase is not None + has_pem = self.certificate is not None or self.private_key is not None + + if has_pfx and has_pem: + msg = "Provide either PFX credentials (pfx + passphrase) or certificate/private_key, not both." + raise ValueError(msg) + if has_pfx: + if not self.pfx_credential or not self.pfx_passphrase: + msg = "Both pfx and passphrase are required when supplying PFX credentials." + raise ValueError(msg) + elif has_pem: + if not self.certificate or not self.private_key: + msg = "Both certificate and private_key are required when supplying PEM/DER credentials." + raise ValueError(msg) + else: + msg = "Either PFX credentials (pfx + passphrase) or certificate/private_key credentials are required." + raise ValueError(msg) + + return self + + class PdfCompressPayload(BaseModel): """Adapt caller options into a pdfRest-ready compress request payload.""" diff --git a/src/pdfrest/types/__init__.py b/src/pdfrest/types/__init__.py index f305cd9e..8e2c0699 100644 --- a/src/pdfrest/types/__init__.py +++ b/src/pdfrest/types/__init__.py @@ -24,6 +24,10 @@ PdfRedactionType, PdfRestriction, PdfRGBColor, + PdfSignatureConfiguration, + PdfSignatureCredentials, + PdfSignatureDisplay, + PdfSignatureLocation, PdfXType, PngColorModel, SummaryFormat, @@ -57,6 +61,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfXType", "PngColorModel", "SummaryFormat", diff --git a/src/pdfrest/types/public.py b/src/pdfrest/types/public.py index 27821ec4..0ffa40be 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -36,6 +36,10 @@ "PdfRedactionPreset", "PdfRedactionType", "PdfRestriction", + "PdfSignatureConfiguration", + "PdfSignatureCredentials", + "PdfSignatureDisplay", + "PdfSignatureLocation", "PdfXType", "PngColorModel", "SummaryFormat", @@ -136,6 +140,47 @@ class PdfMergeSource(TypedDict, total=False): PdfMergeInput = PdfRestFile | PdfMergeSource | tuple[PdfRestFile, PdfPageSelection] + +class PdfSignaturePoint(TypedDict): + x: str | int | float + y: str | int | float + + +class PdfSignatureLocation(TypedDict): + bottom_left: Required[PdfSignaturePoint] + top_right: Required[PdfSignaturePoint] + page: Required[str | int] + + +class PdfSignatureDisplay(TypedDict, total=False): + include_distinguished_name: bool + include_datetime: bool + contact: str + location: str + name: str + reason: str + + +class PdfSignatureConfiguration(TypedDict, total=False): + type: Required[Literal["new"]] + 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 2e11da2143d015c755c1d752cce95c9115dd0ca1 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Tue, 20 Jan 2026 15:11:46 -0600 Subject: [PATCH 2/3] 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 00000000..68a9cf92 --- /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 00000000..226801fc --- /dev/null +++ b/tests/resources/signing_certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUF0/LIPW1an831Oa3vegdRgAcvZgwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MB4XDTI2MDExNjIyNDQwMVoXDTM2 +MDExNDIyNDQwMVowFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEApp1yT4py9K3TGZa7PFALZmfmkG8EBvK1WFg2 +jDO7p7jgyeM5a8m91fHdq7MJcTgti3UUoogMwj1wQKRGWzoVyGNLPL4f0UyV7JdW +mzzMjSQhFbc0ZPmZTS2mHNbbf0BgVsmnmvkXzwueV52reZlTaOTwG3M2pJxyb/+f +YXkv8C8CT4dygNXrBm9A6wlvWEX6z5UaniJzMaFTckK1kl2nHceE0W7kXGqQCGzb +Sff8IRH7LAiQaQ29UFdCE3v6kXxaM+HQ7FrHdm80eIJ8YPvxxW2iO+BYYRYnrMeF +5tSeFyDKYbazzjeWDYptnuySyrcQH9IpknqbRXq8L6bj8u21HQIDAQABo1MwUTAd +BgNVHQ4EFgQUhV4AOiZwlC4Uiv3YZfG248wTyiAwHwYDVR0jBBgwFoAUhV4AOiZw +lC4Uiv3YZfG248wTyiAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAkxFvZkOBBpjxGf4lhSEMP3iCm12SMUtc4uDJsyxU3tjsFjIvnmyvr5LTcG0F +pAl6BWaJj2Ex4/mQL6ztqJRy5GINI5ycBaXQAdEyUDY4+SEaYZKPNfCZ1lDAzD+C +LotVxPAiYnxS2pI9n93Av+HThLtkbr8gRpYhND9mGIWxFGxMlIqgWYtmk2frF325 +e8oIKIMUluESo3GIGwHMW1SCx7oxxfbxsP3oOQzGQtAHxouaZjXtCAw7vdtZAuRy +8YbSz1mt5G/S07to9O/NxsvvKum7zY1g38R9j823F4plhHwpyNUlE0yuofH3lxZA +HES2QmYhPJPxHNtmXLFmGFYT8Q== +-----END CERTIFICATE----- diff --git a/tests/resources/signing_credentials.pfx b/tests/resources/signing_credentials.pfx new file mode 100644 index 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 00000000..c6f604f8 --- /dev/null +++ b/tests/resources/signing_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmnXJPinL0rdMZ +lrs8UAtmZ+aQbwQG8rVYWDaMM7unuODJ4zlryb3V8d2rswlxOC2LdRSiiAzCPXBA +pEZbOhXIY0s8vh/RTJXsl1abPMyNJCEVtzRk+ZlNLaYc1tt/QGBWyaea+RfPC55X +nat5mVNo5PAbczaknHJv/59heS/wLwJPh3KA1esGb0DrCW9YRfrPlRqeInMxoVNy +QrWSXacdx4TRbuRcapAIbNtJ9/whEfssCJBpDb1QV0ITe/qRfFoz4dDsWsd2bzR4 +gnxg+/HFbaI74FhhFiesx4Xm1J4XIMphtrPON5YNim2e7JLKtxAf0imSeptFerwv +puPy7bUdAgMBAAECggEASikJcNsEiOD39ctQIqvULywvBXnMdpVAX4bAHM6IB8L0 +Fxhy/gWpYBmMW7jQipsBNrIR0bgxyaFUHgmgoUls2alMm0ha3Cu1Db5c17MLrwT2 +TvahNRKeCCq55dtCjtTmLKsMVZ/q14bp30C4SuMSq70/HFC/cSyLiUtjsxygWEy0 +Uhrjj9lENMtrGY4cOg4E9pb4oyUVQXTEgznVAHawieJIhXaRvbqbo5QLNlEs0xCr +n77v6AIbXs455au/37tQOgprRPQlkzgzaBtBsgth0QITXwgWc3HZEmIFASWEZuHI +G48OEfaon/QxUdMkST92KVc1LhPXNWjG6rAfy+PbJwKBgQDUklubcecj57wDYqsv +X6NaXGjPUqdyuGB1u3Vdzx44uGyPMCPevqp+zcvLbM9ksBpR3RCocvfRJnNWSpFZ +DIe8i264lqCiCCiSiMb13Socoey0zlHknDFxEEAz49CuzRYGl838Tv4mV34h4d2g +SQ0OyaWyvAisFDs+0BI6u/NjuwKBgQDIp4X/rQifY/ar7jPxWH9kGLFyIcioNXMr +md1aRSBYvrJKSjQngIgsl0aR+Bsxm2LfH6Jxta20TiOBdLJLBxsvyuy+vw2LT/lx +Nr8TuyotYvj3bEDqoF6YGGWAyU3k2d/gatm57GHrUwe3fo0+9+D98klZpo8GNIjk +xbp9cuLBBwKBgD17M0mrUQn+fU+RWyexhqKc9ad5JXs1vphupoyCWiBXnvZvGwDS +rqdMSHRGvVlG4eXphWbjEbAJafR8TruttxieT2DOGBmlOG7hZoI3/HUZlEfbIK55 +SoeEBr27V2EnagZwI6ClDDb0uUN9e0dfuYocYnNmlS+IDnalYZBhSgz/AoGABiak +g+7w+bndwO1/aCGXXiEnp2EDvqxMyIRh9bdyw2WtH3vg12koQ32rqyPY6Y9i24Yj +u6qfFYzjp79FC+m+2ps04LAIoUGlWuQbvWYaZ+PF0AfggZDC9ZSh3+3L1n0bUMzV +uc5WPhmAfg6CE/ETU5WOzBHABqernp+1FM1lyBcCgYBpv/G3nmuLGJxE7ZdEv21H +oKQeMuzT74u3RbLuLYCjRXOa7XEGHN/ehl27MZvXc4Fl4VbPqR29tPVQ1dAyehfu +0GQZiJuS4Z+o7P5/BVmK/W98NTOgUHCiF2BNO4WND3qRev7rZqMdKtZgJ+cJvZ98 +9rhoMDzdXrqa8CzDOfqEbw== +-----END PRIVATE KEY----- diff --git a/tests/resources/signing_logo.png b/tests/resources/signing_logo.png new file mode 100644 index 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 00000000..f3097ab1 --- /dev/null +++ b/tests/resources/signing_passphrase.txt @@ -0,0 +1 @@ +password diff --git a/tests/resources/signing_private_key.der b/tests/resources/signing_private_key.der new file mode 100644 index 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 00000000..d9cdbce1 --- /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 503c84ceeef6a35d538363ede3af1e456745d0e5 Mon Sep 17 00:00:00 2001 From: Christopher Green Date: Mon, 16 Feb 2026 14:54:26 -0600 Subject: [PATCH 3/3] 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 85a1748c..ac467393 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -637,6 +637,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 0ffa40be..629d0bfb 100644 --- a/src/pdfrest/types/public.py +++ b/src/pdfrest/types/public.py @@ -163,9 +163,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 68a9cf92..3188d009 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 d9cdbce1..5b1ffb16 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,