diff --git a/AGENTS.md b/AGENTS.md index 7f627a12..cf46ec1d 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 @@ -128,6 +131,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 diff --git a/src/pdfrest/client.py b/src/pdfrest/client.py index 05976b6a..49f65d8f 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, + "credentials": 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, + "credentials": 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 3e5f7f02..dec907e8 100644 --- a/src/pdfrest/models/_internal.py +++ b/src/pdfrest/models/_internal.py @@ -229,6 +229,12 @@ def _serialize_text_objects(value: list[BaseModel]) -> str: return to_json(payload).decode() +def _serialize_signature_configuration( + value: _PdfSignatureConfigurationModel, +) -> str: + return value.model_dump_json(exclude_none=True) + + def _allowed_mime_types( allowed_mime_types: str, *more_allowed_mime_types: str, error_msg: str | None ) -> Callable[[Any], Any]: @@ -242,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 @@ -978,6 +987,44 @@ class PdfPresetRedactionModel(BaseModel): value: PdfRedactionPreset +class _PdfSignaturePointModel(BaseModel): + x: float + y: 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", "existing"] + name: str | None = None + logo_opacity: Annotated[float | None, Field(gt=0, le=1, default=None)] = None + location: _PdfSignatureLocationModel | None = None + display: _PdfSignatureDisplayModel | None = None + + @model_validator(mode="after") + 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. " + "See documentation for required fields." + ) + raise ValueError(msg) + return self + + _PdfRedactionVariant = Annotated[ PdfLiteralRedactionModel | PdfRegexRedactionModel | PdfPresetRedactionModel, Field(discriminator="type"), @@ -1435,6 +1482,190 @@ 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), + AfterValidator( + _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), + AfterValidator( + _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), + 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", + ) + ), + 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), + 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", + ) + ), + 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), + AfterValidator( + _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="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 ValueError(msg) # noqa: TRY004 + + 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 + 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 aafe2453..389e3346 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 09683eb6..742d3fd9 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,61 @@ 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: float + y: 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 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] + + +class PdfPemCredentials(TypedDict): + certificate: Required[PdfRestFile] + private_key: Required[PdfRestFile] + + +PdfSignatureCredentials = PdfPfxCredentials | PdfPemCredentials + PdfAType = Literal["PDF/A-1b", "PDF/A-2b", "PDF/A-2u", "PDF/A-3b", "PDF/A-3u"] PdfXType = Literal["PDF/X-1a", "PDF/X-3", "PDF/X-4", "PDF/X-6"] ExtractTextGranularity = Literal["off", "by_page", "document"] diff --git a/tests/live/test_live_sign_pdf.py b/tests/live/test_live_sign_pdf.py new file mode 100644 index 00000000..b852d89b --- /dev/null +++ b/tests/live/test_live_sign_pdf.py @@ -0,0 +1,609 @@ +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 { + "bottom_left": {"x": 1, "y": 1}, + "top_right": {"x": 217, "y": 73}, + "page": 1, + } + + +@pytest.fixture(scope="module") +def uploaded_pdf_for_signing( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("report.pdf") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_pfx_credential( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_credentials.pfx") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_passphrase( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_passphrase.txt") + 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( + [(resource.name, sanitized_passphrase.encode("utf-8"), "text/plain")] + )[0] + + +@pytest.fixture(scope="module") +def uploaded_certificate( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_certificate.pem") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_private_key( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_private_key.der") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +@pytest.fixture(scope="module") +def uploaded_logo( + pdfrest_api_key: str, + pdfrest_live_base_url: str, +) -> PdfRestFile: + resource = get_test_resource_path("signing_logo.png") + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + return client.files.create_from_paths([resource])[0] + + +def test_live_sign_pdf_with_pfx_credentials( + pdfrest_api_key: str, + pdfrest_live_base_url: str, + uploaded_pdf_for_signing: PdfRestFile, + uploaded_pfx_credential: PdfRestFile, + uploaded_passphrase: PdfRestFile, +) -> None: + signature_configuration = { + "type": "new", + "name": "pdfrest-live", + "location": make_signature_location(), + } + with PdfRestClient( + api_key=pdfrest_api_key, + base_url=pdfrest_live_base_url, + ) as client: + response = client.sign_pdf( + uploaded_pdf_for_signing, + signature_configuration=signature_configuration, + credentials={ + "pfx": uploaded_pfx_credential, + "passphrase": uploaded_passphrase, + }, + output="live-signed-pfx", + ) + + assert response.output_file.type == "application/pdf" + assert response.output_file.name == "live-signed-pfx.pdf" + assert str(uploaded_pdf_for_signing.id) in response.input_ids + + +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.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, + 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", + "name": "live-async-signature", + "logo_opacity": 0.5, + "location": make_signature_location(), + } + 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 + + +@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, + 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"JSON data provided is not properly 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": "not-json"}, + ) + + +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, + 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"}, + ) + + +@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/resources/signing_certificate.pem b/tests/resources/signing_certificate.pem new file mode 100644 index 00000000..226801fc --- /dev/null +++ b/tests/resources/signing_certificate.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDDzCCAfegAwIBAgIUF0/LIPW1an831Oa3vegdRgAcvZgwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MB4XDTI2MDExNjIyNDQwMVoXDTM2 +MDExNDIyNDQwMVowFzEVMBMGA1UEAwwMcGRmcmVzdC10ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEApp1yT4py9K3TGZa7PFALZmfmkG8EBvK1WFg2 +jDO7p7jgyeM5a8m91fHdq7MJcTgti3UUoogMwj1wQKRGWzoVyGNLPL4f0UyV7JdW +mzzMjSQhFbc0ZPmZTS2mHNbbf0BgVsmnmvkXzwueV52reZlTaOTwG3M2pJxyb/+f +YXkv8C8CT4dygNXrBm9A6wlvWEX6z5UaniJzMaFTckK1kl2nHceE0W7kXGqQCGzb +Sff8IRH7LAiQaQ29UFdCE3v6kXxaM+HQ7FrHdm80eIJ8YPvxxW2iO+BYYRYnrMeF +5tSeFyDKYbazzjeWDYptnuySyrcQH9IpknqbRXq8L6bj8u21HQIDAQABo1MwUTAd +BgNVHQ4EFgQUhV4AOiZwlC4Uiv3YZfG248wTyiAwHwYDVR0jBBgwFoAUhV4AOiZw +lC4Uiv3YZfG248wTyiAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAkxFvZkOBBpjxGf4lhSEMP3iCm12SMUtc4uDJsyxU3tjsFjIvnmyvr5LTcG0F +pAl6BWaJj2Ex4/mQL6ztqJRy5GINI5ycBaXQAdEyUDY4+SEaYZKPNfCZ1lDAzD+C +LotVxPAiYnxS2pI9n93Av+HThLtkbr8gRpYhND9mGIWxFGxMlIqgWYtmk2frF325 +e8oIKIMUluESo3GIGwHMW1SCx7oxxfbxsP3oOQzGQtAHxouaZjXtCAw7vdtZAuRy +8YbSz1mt5G/S07to9O/NxsvvKum7zY1g38R9j823F4plhHwpyNUlE0yuofH3lxZA +HES2QmYhPJPxHNtmXLFmGFYT8Q== +-----END CERTIFICATE----- diff --git a/tests/resources/signing_credentials.pfx b/tests/resources/signing_credentials.pfx new file mode 100644 index 00000000..b42c7348 Binary files /dev/null and b/tests/resources/signing_credentials.pfx differ diff --git a/tests/resources/signing_key.pem b/tests/resources/signing_key.pem new file mode 100644 index 00000000..c6f604f8 --- /dev/null +++ b/tests/resources/signing_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmnXJPinL0rdMZ +lrs8UAtmZ+aQbwQG8rVYWDaMM7unuODJ4zlryb3V8d2rswlxOC2LdRSiiAzCPXBA +pEZbOhXIY0s8vh/RTJXsl1abPMyNJCEVtzRk+ZlNLaYc1tt/QGBWyaea+RfPC55X +nat5mVNo5PAbczaknHJv/59heS/wLwJPh3KA1esGb0DrCW9YRfrPlRqeInMxoVNy +QrWSXacdx4TRbuRcapAIbNtJ9/whEfssCJBpDb1QV0ITe/qRfFoz4dDsWsd2bzR4 +gnxg+/HFbaI74FhhFiesx4Xm1J4XIMphtrPON5YNim2e7JLKtxAf0imSeptFerwv +puPy7bUdAgMBAAECggEASikJcNsEiOD39ctQIqvULywvBXnMdpVAX4bAHM6IB8L0 +Fxhy/gWpYBmMW7jQipsBNrIR0bgxyaFUHgmgoUls2alMm0ha3Cu1Db5c17MLrwT2 +TvahNRKeCCq55dtCjtTmLKsMVZ/q14bp30C4SuMSq70/HFC/cSyLiUtjsxygWEy0 +Uhrjj9lENMtrGY4cOg4E9pb4oyUVQXTEgznVAHawieJIhXaRvbqbo5QLNlEs0xCr +n77v6AIbXs455au/37tQOgprRPQlkzgzaBtBsgth0QITXwgWc3HZEmIFASWEZuHI +G48OEfaon/QxUdMkST92KVc1LhPXNWjG6rAfy+PbJwKBgQDUklubcecj57wDYqsv +X6NaXGjPUqdyuGB1u3Vdzx44uGyPMCPevqp+zcvLbM9ksBpR3RCocvfRJnNWSpFZ +DIe8i264lqCiCCiSiMb13Socoey0zlHknDFxEEAz49CuzRYGl838Tv4mV34h4d2g +SQ0OyaWyvAisFDs+0BI6u/NjuwKBgQDIp4X/rQifY/ar7jPxWH9kGLFyIcioNXMr +md1aRSBYvrJKSjQngIgsl0aR+Bsxm2LfH6Jxta20TiOBdLJLBxsvyuy+vw2LT/lx +Nr8TuyotYvj3bEDqoF6YGGWAyU3k2d/gatm57GHrUwe3fo0+9+D98klZpo8GNIjk +xbp9cuLBBwKBgD17M0mrUQn+fU+RWyexhqKc9ad5JXs1vphupoyCWiBXnvZvGwDS +rqdMSHRGvVlG4eXphWbjEbAJafR8TruttxieT2DOGBmlOG7hZoI3/HUZlEfbIK55 +SoeEBr27V2EnagZwI6ClDDb0uUN9e0dfuYocYnNmlS+IDnalYZBhSgz/AoGABiak +g+7w+bndwO1/aCGXXiEnp2EDvqxMyIRh9bdyw2WtH3vg12koQ32rqyPY6Y9i24Yj +u6qfFYzjp79FC+m+2ps04LAIoUGlWuQbvWYaZ+PF0AfggZDC9ZSh3+3L1n0bUMzV +uc5WPhmAfg6CE/ETU5WOzBHABqernp+1FM1lyBcCgYBpv/G3nmuLGJxE7ZdEv21H +oKQeMuzT74u3RbLuLYCjRXOa7XEGHN/ehl27MZvXc4Fl4VbPqR29tPVQ1dAyehfu +0GQZiJuS4Z+o7P5/BVmK/W98NTOgUHCiF2BNO4WND3qRev7rZqMdKtZgJ+cJvZ98 +9rhoMDzdXrqa8CzDOfqEbw== +-----END PRIVATE KEY----- diff --git a/tests/resources/signing_logo.png b/tests/resources/signing_logo.png new file mode 100644 index 00000000..c9b56ad7 Binary files /dev/null and b/tests/resources/signing_logo.png differ diff --git a/tests/resources/signing_passphrase.txt b/tests/resources/signing_passphrase.txt new file mode 100644 index 00000000..f3097ab1 --- /dev/null +++ b/tests/resources/signing_passphrase.txt @@ -0,0 +1 @@ +password diff --git a/tests/resources/signing_private_key.der b/tests/resources/signing_private_key.der new file mode 100644 index 00000000..87a27ef3 Binary files /dev/null and b/tests/resources/signing_private_key.der differ diff --git a/tests/test_sign_pdf.py b/tests/test_sign_pdf.py new file mode 100644 index 00000000..01bbbec0 --- /dev/null +++ b/tests/test_sign_pdf.py @@ -0,0 +1,1046 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +import pytest +from pydantic import ValidationError + +from pdfrest import AsyncPdfRestClient, PdfRestClient +from pdfrest.models import PdfRestFile, PdfRestFileBasedResponse, PdfRestFileID +from pdfrest.models._internal import PdfSignPayload + +from .graphics_test_helpers import ( + ASYNC_API_KEY, + VALID_API_KEY, + build_file_info_payload, + make_pdf_file, +) + + +def make_pfx_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "signer.pfx", "application/x-pkcs12") + ) + + +def make_passphrase_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "passphrase.txt", "text/plain") + ) + + +def make_certificate_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "certificate.pem", "application/pkix-cert") + ) + + +def make_private_key_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "private_key.der", "application/pkcs8") + ) + + +def make_logo_file(file_id: str) -> PdfRestFile: + return PdfRestFile.model_validate( + build_file_info_payload(file_id, "logo.png", "image/png") + ) + + +def make_signature_location() -> dict[str, dict[str, int] | int]: + return { + "bottom_left": {"x": 0, "y": 0}, + "top_right": {"x": 216, "y": 72}, + "page": 1, + } + + +def 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"} + + +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)) + 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", + "name": "esignature", + "location": make_signature_location(), + "display": {"include_datetime": True, "name": "Signer"}, + } + payload_dump = PdfSignPayload.model_validate( + { + "files": [input_file], + "signature_configuration": signature_configuration, + "credentials": {"pfx": pfx_file, "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": + seen["post"] += 1 + 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}": + seen["get"] += 1 + 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} + assert seen == {"post": 1, "get": 1} + + +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()) + seen = {"post": 0, "get": 0} + + 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, + "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) + + 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( + 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}": + seen["get"] += 1 + 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 + assert seen == {"post": 1, "get": 1} + + +def test_sign_pdf_requires_credential_pair( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + pfx_file = make_pfx_file(str(PdfRestFileID.generate())) + transport = httpx.MockTransport(lambda request: (_ for _ in ()).throw(RuntimeError)) + + with ( + PdfRestClient(api_key=VALID_API_KEY, transport=transport) as client, + pytest.raises(ValidationError, match=r"Both pfx and passphrase"), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={"pfx": pfx_file}, + ) + + +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: + 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}, + ) + + +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\n\s+List should have at most 1 item after validation", + ), + ): + 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\n\s+List should have at most 1 item after validation", + ), + ): + 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.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()) + 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=expected_match), + ): + client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + multi_field: [multi_file_a, multi_file_b], # type: ignore[dict-item] + single_field: single_file, + }, + ) + + +@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.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\n\s+List should have at most 1 item after validation", + ): + 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\n\s+List should have at most 1 item after validation", + ): + 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 +@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()) + 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=expected_match): + await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + multi_field: [multi_file_a, multi_file_b], # type: ignore[dict-item] + single_field: single_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_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: + 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] = {} + 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") + 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}": + seen["get"] += 1 + 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) + assert seen == {"post": 1, "get": 1} + + +@pytest.mark.asyncio +async def test_async_sign_pdf_request_customization( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("PDFREST_API_KEY", raising=False) + input_file = make_pdf_file(PdfRestFileID.generate()) + certificate_file = make_certificate_file(str(PdfRestFileID.generate())) + private_key_file = make_private_key_file(str(PdfRestFileID.generate())) + output_id = str(PdfRestFileID.generate()) + captured_timeout: dict[str, float | dict[str, float] | None] = {} + 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") + 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}": + seen["get"] += 1 + assert request.url.params["trace"] == "async" + assert request.headers["X-Debug"] == "async-sign" + return httpx.Response( + 200, + json=build_file_info_payload( + output_id, + "async-signed.pdf", + "application/pdf", + ), + ) + msg = f"Unexpected request {request.method} {request.url}" + raise AssertionError(msg) + + transport = httpx.MockTransport(handler) + async with AsyncPdfRestClient( + api_key=ASYNC_API_KEY, + transport=transport, + ) as client: + response = await client.sign_pdf( + input_file, + signature_configuration={ + "type": "new", + "location": make_signature_location(), + }, + credentials={ + "certificate": certificate_file, + "private_key": private_key_file, + }, + output="async-signed", + extra_query={"trace": "async"}, + extra_headers={"X-Debug": "async-sign"}, + extra_body={"diagnostics": "on"}, + timeout=0.5, + ) + + assert isinstance(response, PdfRestFileBasedResponse) + assert response.output_file.name == "async-signed.pdf" + timeout_value = captured_timeout["value"] + assert timeout_value is not None + if isinstance(timeout_value, dict): + assert all(pytest.approx(0.5) == value for value in timeout_value.values()) + else: + assert timeout_value == pytest.approx(0.5) + assert seen == {"post": 1, "get": 1} + + +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" + + +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" + + +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 + + +@pytest.mark.parametrize( + "logo_opacity", + [ + pytest.param(0.01, 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.0, id="zero"), + 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 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}, + } + )