From b74d4399b51f90e906abce40266d79d89bae3a3c Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 6 Nov 2026 11:14:18 +0100 Subject: [PATCH 1/4] Security hardening: input validation for request paths --- README.md | 5 + checkout_sdk/apm/sepa_client.py | 12 +- checkout_sdk/client.py | 47 ++- checkout_sdk/events/events_client.py | 6 +- .../instruments/instruments_client.py | 5 +- .../payments/googlepay/googlepay_client.py | 12 +- .../payments/sessions/sessions_client.py | 7 +- tests/client_test.py | 293 ++++++++++++++++++ .../compliance_requests_client_test.py | 5 +- 9 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 tests/client_test.py diff --git a/README.md b/README.md index 71e1320f..0b34c731 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,11 @@ except CheckoutApiException as err: error_details = err.error_details ``` +Identifiers passed to client methods (for example a payment id) are sent as single, percent-encoded path +segments: pass them unencoded (Enum members are sent by their value). A `CheckoutArgumentException` is raised +before any request is sent when an identifier is not a string, is `None`, empty, `.` or `..`, cannot be encoded as UTF-8, or contains `/`, `\` or an +ASCII control character (U+0000 to U+001F, U+007F). + ## Building from source ### Prerequisites diff --git a/checkout_sdk/apm/sepa_client.py b/checkout_sdk/apm/sepa_client.py index 505c234b..19ca8946 100644 --- a/checkout_sdk/apm/sepa_client.py +++ b/checkout_sdk/apm/sepa_client.py @@ -8,7 +8,8 @@ class SepaClient(Client): __APMS_PATH = 'apms' - __SEPA_MANDATES_PATH = 'sepa/mandates' + __SEPA_PATH = 'sepa' + __MANDATES_PATH = 'mandates' __PPRO_PATH = 'ppro' __CANCEL_PATH = 'cancel' @@ -18,18 +19,19 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY) def get_mandate(self, mandate_id: str): - return self._api_client.get(self.build_path(self.__SEPA_MANDATES_PATH, mandate_id), self._sdk_authorization()) + return self._api_client.get(self.build_path(self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id), + self._sdk_authorization()) def cancel_mandate(self, mandate_id: str): - return self._api_client.post(self.build_path(self.__SEPA_MANDATES_PATH, mandate_id, self.__CANCEL_PATH), + return self._api_client.post(self.build_path(self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id, self.__CANCEL_PATH), self._sdk_authorization()) def get_mandate_via_ppro(self, mandate_id: str): return self._api_client.get(self.build_path(self.__APMS_PATH, self.__PPRO_PATH, - self.__SEPA_MANDATES_PATH, mandate_id), + self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id), self._sdk_authorization()) def cancel_mandate_via_ppro(self, mandate_id: str): return self._api_client.post(self.build_path(self.__APMS_PATH, self.__PPRO_PATH, - self.__SEPA_MANDATES_PATH, mandate_id, self.__CANCEL_PATH), + self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id, self.__CANCEL_PATH), self._sdk_authorization()) diff --git a/checkout_sdk/client.py b/checkout_sdk/client.py index a145d4b4..9682bfb6 100644 --- a/checkout_sdk/client.py +++ b/checkout_sdk/client.py @@ -1,8 +1,17 @@ from __future__ import absolute_import +import re +from enum import Enum +from urllib.parse import quote + from checkout_sdk.api_client import ApiClient from checkout_sdk.authorization_type import AuthorizationType from checkout_sdk.checkout_configuration import CheckoutConfiguration +from checkout_sdk.exception import CheckoutArgumentException + +_FORBIDDEN_SEGMENT_CHARACTERS = re.compile(r'[/\\\x00-\x1f\x7f]') +_INVALID_SEGMENT_MESSAGE = ("Invalid path segment: it must be a non-empty string, not '.' or '..', " + "without '/', '\\' or ASCII control characters (U+0000 to U+001F, U+007F)") class Client: @@ -25,4 +34,40 @@ def is_sandbox(self): @staticmethod def build_path(*params): - return '/'.join(params) + """Join path segments into a request path, encoding each one as a single segment. + + Every argument is percent-encoded (RFC 3986, only ``A-Z a-z 0-9 - . _ ~`` kept), so + ``?``, ``#``, ``%`` and spaces stay inside their segment. Pass identifiers unencoded: an + already encoded value is encoded again. Enum members are sent by their value. + + Args: + *params: the path segments, path constants and caller identifiers alike. + + Returns: + str: the segments joined with ``/``. + + Raises: + CheckoutArgumentException: a segment is not a string (or a string-valued Enum + member), is empty, ``.`` or ``..``, contains ``/``, ``\\`` or an ASCII control + character (U+0000 to U+001F, U+007F), or cannot be encoded as UTF-8. + """ + return '/'.join(Client._encode_path_segment(param) for param in params) + + @staticmethod + def _encode_path_segment(value): + # type() rather than isinstance(): a __class__ property cannot change it. + if issubclass(type(value), Enum): + value = value.value + if not issubclass(type(value), str): + raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) + # Exact str copy of the characters, so no overridden method of a str subclass takes part. + value = str.__str__(value) + if _FORBIDDEN_SEGMENT_CHARACTERS.search(value): + raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) + try: + encoded = quote(value, safe='') + except UnicodeEncodeError: + raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) from None + if encoded in ('', '.', '..'): + raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) + return encoded diff --git a/checkout_sdk/events/events_client.py b/checkout_sdk/events/events_client.py index d7333664..8a5a999f 100644 --- a/checkout_sdk/events/events_client.py +++ b/checkout_sdk/events/events_client.py @@ -19,10 +19,8 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.SECRET_KEY) def retrieve_all_event_types(self, version: str = None): - event_types_path = "event-types" - if version is not None: - event_types_path += "?version=" + version - return self._api_client.get(event_types_path, self._sdk_authorization()) + params = None if version is None else {'version': version} + return self._api_client.get('event-types', self._sdk_authorization(), params) def retrieve_events(self, events_request: RetrieveEventsRequest): return self._api_client.get(self.__EVENTS_PATH, self._sdk_authorization(), events_request) diff --git a/checkout_sdk/instruments/instruments_client.py b/checkout_sdk/instruments/instruments_client.py index 156bd75e..22d4b454 100644 --- a/checkout_sdk/instruments/instruments_client.py +++ b/checkout_sdk/instruments/instruments_client.py @@ -11,7 +11,8 @@ class InstrumentsClient(Client): __INSTRUMENTS = 'instruments' - __BANK_VALIDATION_PATH = 'validation/bank-accounts' + __VALIDATION_PATH = 'validation' + __BANK_ACCOUNTS_PATH = 'bank-accounts' def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): super().__init__(api_client=api_client, @@ -40,5 +41,5 @@ def revoke(self, instrument_id: str): def get_bank_account_field_formatting(self, country: Country, currency: Currency, bank_account_field_query: BankAccountFieldQuery): - return self._api_client.get(self.build_path(self.__BANK_VALIDATION_PATH, country, currency), + return self._api_client.get(self.build_path(self.__VALIDATION_PATH, self.__BANK_ACCOUNTS_PATH, country, currency), self._sdk_authorization(AuthorizationType.OAUTH), bank_account_field_query) diff --git a/checkout_sdk/payments/googlepay/googlepay_client.py b/checkout_sdk/payments/googlepay/googlepay_client.py index d62ae90d..84ec56f6 100644 --- a/checkout_sdk/payments/googlepay/googlepay_client.py +++ b/checkout_sdk/payments/googlepay/googlepay_client.py @@ -8,7 +8,8 @@ class GooglePayClient(Client): - __GOOGLEPAY_ENROLLMENTS_PATH = 'googlepay/enrollments' + __GOOGLEPAY_PATH = 'googlepay' + __ENROLLMENTS_PATH = 'enrollments' __DOMAIN_PATH = 'domain' __DOMAINS_PATH = 'domains' __STATE_PATH = 'state' @@ -19,20 +20,21 @@ def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): authorization_type=AuthorizationType.OAUTH) def create_enrollment(self, request: GooglePayEnrollmentRequest): - return self._api_client.post(self.__GOOGLEPAY_ENROLLMENTS_PATH, self._sdk_authorization(), request) + return self._api_client.post(self.build_path(self.__GOOGLEPAY_PATH, self.__ENROLLMENTS_PATH), + self._sdk_authorization(), request) def register_domain(self, entity_id: str, request: GooglePayRegisterDomainRequest): return self._api_client.post( - self.build_path(self.__GOOGLEPAY_ENROLLMENTS_PATH, entity_id, self.__DOMAIN_PATH), + self.build_path(self.__GOOGLEPAY_PATH, self.__ENROLLMENTS_PATH, entity_id, self.__DOMAIN_PATH), self._sdk_authorization(), request) def get_registered_domains(self, entity_id: str): return self._api_client.get( - self.build_path(self.__GOOGLEPAY_ENROLLMENTS_PATH, entity_id, self.__DOMAINS_PATH), + self.build_path(self.__GOOGLEPAY_PATH, self.__ENROLLMENTS_PATH, entity_id, self.__DOMAINS_PATH), self._sdk_authorization()) def get_enrollment_state(self, entity_id: str): return self._api_client.get( - self.build_path(self.__GOOGLEPAY_ENROLLMENTS_PATH, entity_id, self.__STATE_PATH), + self.build_path(self.__GOOGLEPAY_PATH, self.__ENROLLMENTS_PATH, entity_id, self.__STATE_PATH), self._sdk_authorization()) diff --git a/checkout_sdk/payments/sessions/sessions_client.py b/checkout_sdk/payments/sessions/sessions_client.py index 41b3fa89..c6e2e099 100644 --- a/checkout_sdk/payments/sessions/sessions_client.py +++ b/checkout_sdk/payments/sessions/sessions_client.py @@ -11,6 +11,8 @@ class PaymentSessionsClient(Client): __PAYMENT_SESSIONS_PATH = 'payment-sessions' + __COMPLETE_PATH = 'complete' + __SUBMIT_PATH = 'submit' def __init__(self, api_client: ApiClient, configuration: CheckoutConfiguration): super().__init__(api_client=api_client, @@ -23,11 +25,12 @@ def create_payment_sessions(self, payment_sessions_request: PaymentSessionsReque def create_payment_session_with_payment( self, payment_session_with_payment_request: PaymentSessionWithPaymentRequest): - return self._api_client.post(self.__PAYMENT_SESSIONS_PATH + '/complete', self._sdk_authorization(), + return self._api_client.post(self.build_path(self.__PAYMENT_SESSIONS_PATH, self.__COMPLETE_PATH), + self._sdk_authorization(), payment_session_with_payment_request) def submit_payment_session(self, session_id: str, submit_payment_session_request: SubmitPaymentSessionRequest): - return self._api_client.post(self.__PAYMENT_SESSIONS_PATH + '/' + session_id + '/submit', + return self._api_client.post(self.build_path(self.__PAYMENT_SESSIONS_PATH, session_id, self.__SUBMIT_PATH), self._sdk_authorization(), submit_payment_session_request) diff --git a/tests/client_test.py b/tests/client_test.py new file mode 100644 index 00000000..978f2e96 --- /dev/null +++ b/tests/client_test.py @@ -0,0 +1,293 @@ +from enum import Enum + +import pytest +import requests +from requests.adapters import BaseAdapter + +from checkout_sdk.api_client import ApiClient +from checkout_sdk.accounts.accounts_client import AccountsClient +from checkout_sdk.apm.klarna_client import KlarnaClient +from checkout_sdk.apm.sepa_client import SepaClient +from checkout_sdk.checkout_configuration import CheckoutConfiguration +from checkout_sdk.client import Client +from checkout_sdk.common.enums import Country, Currency +from checkout_sdk.customers.customers_client import CustomersClient +from checkout_sdk.disputes.disputes_client import DisputesClient +from checkout_sdk.environment import Environment +from checkout_sdk.events.events_client import EventsClient +from checkout_sdk.exception import CheckoutArgumentException +from checkout_sdk.instruments.instruments_client import InstrumentsClient +from checkout_sdk.issuing.issuing_client import IssuingClient +from checkout_sdk.payments.payments_client import PaymentsClient +from checkout_sdk.payments.sessions.sessions_client import PaymentSessionsClient +from checkout_sdk.payments.setups.setups_client import PaymentSetupsClient +from checkout_sdk.workflows.workflows_client import WorkflowsClient + +BASE_URI = 'https://api.example.test/' + + +class _Authorization: + + @staticmethod + def get_authorization_header(): + return 'Bearer test' + + +class _Credentials: + + @staticmethod + def get_authorization(_authorization_type): + return _Authorization() + + +class _RecordingAdapter(BaseAdapter): + """Transport adapter that records the prepared request instead of sending it.""" + + def __init__(self): + super().__init__() + self.requests = [] + + def send(self, request, **kwargs): + self.requests.append(request) + response = requests.Response() + response.status_code = 200 + response.headers['Content-Type'] = 'application/json' + response._content = b'' + response.request = request + return response + + def close(self): + pass + + +@pytest.fixture +def wire(): + """Real ApiClient over a real requests.Session; returns (configuration, api_client, adapter).""" + adapter = _RecordingAdapter() + session = requests.Session() + session.mount('https://', adapter) + configuration = CheckoutConfiguration(_Credentials(), Environment.sandbox(), session) + return configuration, ApiClient(configuration, BASE_URI), adapter + + +def _sent_path(adapter): + assert len(adapter.requests) == 1, f'expected one request, got {len(adapter.requests)}' + return adapter.requests[0].path_url + + +class _Plain(str, Enum): + VALUE = 'GB' + + +class _ZeroLength(str): + """str subclass that reports itself as empty and never equal to anything.""" + + def __len__(self): + return 0 + + def __bool__(self): + return False + + def __eq__(self, other): + return False + + def __hash__(self): + return 0 + + def __str__(self): + return 'ok' + + +class TestBuildPath: + + @pytest.mark.parametrize('segments, expected', [ + (('payments', 'pay_abc123'), 'payments/pay_abc123'), + (('payments', 'pay_abc123', 'voids'), 'payments/pay_abc123/voids'), + (('payments', 'pay-by.bank_~1'), 'payments/pay-by.bank_~1'), + (('payments',), 'payments'), + (('validation', 'bank-accounts', Country.GB, Currency.GBP), 'validation/bank-accounts/GB/GBP'), + (('x', _Plain.VALUE), 'x/GB'), + ]) + def test_should_keep_valid_segments_unchanged(self, segments, expected): + assert Client.build_path(*segments) == expected + + @pytest.mark.parametrize('value, expected', [ + ('a?b', 'a%3Fb'), + ('a#b', 'a%23b'), + ('a?limit=1&b=2', 'a%3Flimit%3D1%26b%3D2'), + ('a b', 'a%20b'), + ('a%2Fb', 'a%252Fb'), + ('%2e%2e', '%252e%252e'), + ('a;b', 'a%3Bb'), + ('a:b@c', 'a%3Ab%40c'), + ('...', '...'), + ('café', 'caf%C3%A9'), + ('a\u0085b', 'a%C2%85b'), + ('a..', 'a%EF%BC%8E%EF%BC%8E'), + ]) + def test_should_encode_each_value_as_one_segment(self, value, expected): + assert Client.build_path('payments', value) == 'payments/' + expected + + @pytest.mark.parametrize('value', [ + None, '', '.', '..', + '../x', '..\\x', 'a/b', 'a\\b', '/', 'x/..', + 'a\x00b', 'a\rb', 'a\nb', 'a\tb', 'a\x7fb', + 'a\ud800b', + 123, 1.5, True, b'pay_x', object(), + ]) + def test_should_reject_invalid_segments(self, value): + with pytest.raises(CheckoutArgumentException): + Client.build_path('payments', value) + + @pytest.mark.parametrize('content, expected', [ + ('pay?x=1#frag', 'payments/pay%3Fx%3D1%23frag'), + ('ok', 'payments/ok'), + ]) + def test_should_encode_str_subclass_by_its_content(self, content, expected): + assert Client.build_path('payments', _ZeroLength(content)) == expected + assert type(Client.build_path('payments', _ZeroLength(content))) is str + + @pytest.mark.parametrize('content', ['..', '.', '']) + def test_should_reject_str_subclass_dot_segments(self, content): + with pytest.raises(CheckoutArgumentException): + Client.build_path('payments', _ZeroLength(content)) + + def test_should_encode_enum_with_str_subclass_value_by_its_content(self): + member = Enum('Member', {'VALUE': _ZeroLength('a?b')}).VALUE + assert Client.build_path('x', member) == 'x/a%3Fb' + + def test_should_reject_object_reporting_str_class(self): + class _Disguised: + @property + def __class__(self): + return str + + with pytest.raises(CheckoutArgumentException): + Client.build_path('payments', _Disguised()) + + def test_should_not_echo_rejected_value_in_message(self): + with pytest.raises(CheckoutArgumentException) as error: + Client.build_path('payments', 'secret_value/x') + assert 'secret_value' not in str(error.value) + + +class TestRequestPathOnTheWire: + + def test_should_reject_separator_in_void_id_before_sending(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + with pytest.raises(CheckoutArgumentException): + client.void_payment('pay_x/refunds#') + assert adapter.requests == [] + + def test_should_reject_dot_segments_in_id_before_sending(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + with pytest.raises(CheckoutArgumentException): + client.get_payment_details('../x/y') + assert adapter.requests == [] + + def test_should_keep_query_characters_inside_the_id_segment(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + client.get_payment_details('pay_x?limit=1') + assert _sent_path(adapter) == '/payments/pay_x%3Flimit%3D1' + + def test_should_keep_fragment_characters_inside_the_id_segment(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + client.void_payment('pay_x#') + assert _sent_path(adapter) == '/payments/pay_x%23/voids' + + def test_should_not_decode_percent_encoded_input(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + client.get_payment_details('%2E%2E%2Fx') + assert _sent_path(adapter) == '/payments/%252E%252E%252Fx' + + def test_should_send_valid_id_unchanged(self, wire): + configuration, api_client, adapter = wire + client = PaymentsClient(api_client, configuration) + client.void_payment('pay_abc123') + assert _sent_path(adapter) == '/payments/pay_abc123/voids' + + def test_should_encode_payment_session_id(self, wire): + configuration, api_client, adapter = wire + client = PaymentSessionsClient(api_client, configuration) + client.submit_payment_session('ps_x?a#b', None) + assert _sent_path(adapter) == '/payment-sessions/ps_x%3Fa%23b/submit' + + def test_should_reject_payment_session_id_with_separator(self, wire): + configuration, api_client, adapter = wire + client = PaymentSessionsClient(api_client, configuration) + with pytest.raises(CheckoutArgumentException): + client.submit_payment_session('../x', None) + assert adapter.requests == [] + + def test_should_send_event_types_version_as_query_parameter(self, wire): + configuration, api_client, adapter = wire + client = EventsClient(api_client, configuration) + client.retrieve_all_event_types('1.0&x=y#z') + assert _sent_path(adapter) == '/event-types?version=1.0%26x%3Dy%23z' + + def test_should_send_event_types_without_version(self, wire): + configuration, api_client, adapter = wire + client = EventsClient(api_client, configuration) + client.retrieve_all_event_types() + assert _sent_path(adapter) == '/event-types' + + +def _accounts(api_client, configuration): + return AccountsClient(api_client=api_client, files_client=api_client, configuration=configuration) + + +# (client factory, method name, extra positional args, expected path with '{}' for the encoded id) +_SAMPLE = [ + (PaymentsClient, 'get_payment_details', (), '/payments/{}'), + (PaymentsClient, 'void_payment', (), '/payments/{}/voids'), + (CustomersClient, 'get', (), '/customers/{}'), + (DisputesClient, 'get_dispute_details', (), '/disputes/{}'), + (InstrumentsClient, 'get', (), '/instruments/{}'), + (WorkflowsClient, 'get_workflow', (), '/workflows/{}'), + (IssuingClient, 'get_card_details', (), '/issuing/cards/{}'), + (EventsClient, 'retrieve_event', (), '/events/{}'), + (SepaClient, 'get_mandate', (), '/sepa/mandates/{}'), + (PaymentSetupsClient, 'get_payment_setup', (), '/payments/setups/{}'), + (PaymentSetupsClient, 'confirm_payment_setup', ('klarna',), '/payments/setups/{}/confirm/klarna'), + (KlarnaClient, 'get_credit_session', (), '/klarna-external/credit-sessions/{}'), + (_accounts, 'retrieve_entity_file', ('file_x',), '/entities/{}/files/file_x'), +] + + +class TestSampleOfClients: + + @pytest.mark.parametrize('factory, method, extra, expected', _SAMPLE) + def test_should_send_valid_id_unchanged(self, wire, factory, method, extra, expected): + configuration, api_client, adapter = wire + getattr(factory(api_client, configuration), method)('id_abc-123', *extra) + assert _sent_path(adapter) == expected.format('id_abc-123') + + @pytest.mark.parametrize('factory, method, extra, expected', _SAMPLE) + def test_should_encode_id_as_one_segment(self, wire, factory, method, extra, expected): + configuration, api_client, adapter = wire + getattr(factory(api_client, configuration), method)('a?b#c d', *extra) + assert _sent_path(adapter) == expected.format('a%3Fb%23c%20d') + + @pytest.mark.parametrize('value', ['../x', 'a/b', '..', '', None]) + @pytest.mark.parametrize('factory, method, extra, expected', _SAMPLE) + def test_should_reject_invalid_id_before_sending(self, wire, factory, method, extra, expected, value): + configuration, api_client, adapter = wire + with pytest.raises(CheckoutArgumentException): + getattr(factory(api_client, configuration), method)(value, *extra) + assert adapter.requests == [] + + def test_should_encode_second_caller_value(self, wire): + configuration, api_client, adapter = wire + PaymentSetupsClient(api_client, configuration).confirm_payment_setup('ps_x', 'a?b') + assert _sent_path(adapter) == '/payments/setups/ps_x/confirm/a%3Fb' + + def test_should_reject_second_caller_value_with_separator(self, wire): + configuration, api_client, adapter = wire + with pytest.raises(CheckoutArgumentException): + PaymentSetupsClient(api_client, configuration).confirm_payment_setup('ps_x', '../../x/y') + assert adapter.requests == [] diff --git a/tests/compliancerequests/compliance_requests_client_test.py b/tests/compliancerequests/compliance_requests_client_test.py index 75bd9c8d..ee4b3ad1 100644 --- a/tests/compliancerequests/compliance_requests_client_test.py +++ b/tests/compliancerequests/compliance_requests_client_test.py @@ -3,6 +3,7 @@ from tests._assertions import assert_api_call from checkout_sdk.compliancerequests.compliance_requests_client import ComplianceRequestsClient from checkout_sdk.compliancerequests.compliance_requests import ComplianceRequestRespondRequest +from checkout_sdk.exception import CheckoutArgumentException @pytest.fixture(scope='class') @@ -21,7 +22,7 @@ def test_get_compliance_request(self, mocker, client: ComplianceRequestsClient): def test_get_compliance_request_with_none_payment_id(self, mocker, client: ComplianceRequestsClient): mocker.patch('checkout_sdk.api_client.ApiClient.get', return_value='response') - with pytest.raises(TypeError, match="sequence item 1: expected str"): + with pytest.raises(CheckoutArgumentException, match="Invalid path segment"): client.get_compliance_request(None) def test_respond_to_compliance_request(self, mocker, client: ComplianceRequestsClient): @@ -34,7 +35,7 @@ def test_respond_to_compliance_request(self, mocker, client: ComplianceRequestsC def test_respond_to_compliance_request_with_none_payment_id(self, mocker, client: ComplianceRequestsClient): mocker.patch('checkout_sdk.api_client.ApiClient.post', return_value='response') - with pytest.raises(TypeError, match="sequence item 1: expected str"): + with pytest.raises(CheckoutArgumentException, match="Invalid path segment"): client.respond_to_compliance_request(None, ComplianceRequestRespondRequest()) def test_respond_to_compliance_request_with_none_request(self, mocker, client: ComplianceRequestsClient): From c901775a66f3a5d3e0d1b339523a1e04e1cc873b Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 6 Nov 2026 11:18:33 +0100 Subject: [PATCH 2/4] Test update --- tests/client_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/client_test.py b/tests/client_test.py index 978f2e96..52631d27 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -167,8 +167,8 @@ def __class__(self): def test_should_not_echo_rejected_value_in_message(self): with pytest.raises(CheckoutArgumentException) as error: - Client.build_path('payments', 'secret_value/x') - assert 'secret_value' not in str(error.value) + Client.build_path('payments', 'sample_value/x') + assert 'sample_value' not in str(error.value) class TestRequestPathOnTheWire: @@ -177,7 +177,7 @@ def test_should_reject_separator_in_void_id_before_sending(self, wire): configuration, api_client, adapter = wire client = PaymentsClient(api_client, configuration) with pytest.raises(CheckoutArgumentException): - client.void_payment('pay_x/refunds#') + client.void_payment('pay_x/y#') assert adapter.requests == [] def test_should_reject_dot_segments_in_id_before_sending(self, wire): From 3c450f37c5eb888ff8d2940c97706247007c5f78 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 6 Nov 2026 11:33:25 +0100 Subject: [PATCH 3/4] Pylint format fix, line chunk --- checkout_sdk/apm/sepa_client.py | 6 ++++-- checkout_sdk/instruments/instruments_client.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/checkout_sdk/apm/sepa_client.py b/checkout_sdk/apm/sepa_client.py index 19ca8946..04c655ae 100644 --- a/checkout_sdk/apm/sepa_client.py +++ b/checkout_sdk/apm/sepa_client.py @@ -23,7 +23,8 @@ def get_mandate(self, mandate_id: str): self._sdk_authorization()) def cancel_mandate(self, mandate_id: str): - return self._api_client.post(self.build_path(self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id, self.__CANCEL_PATH), + return self._api_client.post(self.build_path(self.__SEPA_PATH, self.__MANDATES_PATH, + mandate_id, self.__CANCEL_PATH), self._sdk_authorization()) def get_mandate_via_ppro(self, mandate_id: str): @@ -33,5 +34,6 @@ def get_mandate_via_ppro(self, mandate_id: str): def cancel_mandate_via_ppro(self, mandate_id: str): return self._api_client.post(self.build_path(self.__APMS_PATH, self.__PPRO_PATH, - self.__SEPA_PATH, self.__MANDATES_PATH, mandate_id, self.__CANCEL_PATH), + self.__SEPA_PATH, self.__MANDATES_PATH, + mandate_id, self.__CANCEL_PATH), self._sdk_authorization()) diff --git a/checkout_sdk/instruments/instruments_client.py b/checkout_sdk/instruments/instruments_client.py index 22d4b454..7aacd814 100644 --- a/checkout_sdk/instruments/instruments_client.py +++ b/checkout_sdk/instruments/instruments_client.py @@ -41,5 +41,6 @@ def revoke(self, instrument_id: str): def get_bank_account_field_formatting(self, country: Country, currency: Currency, bank_account_field_query: BankAccountFieldQuery): - return self._api_client.get(self.build_path(self.__VALIDATION_PATH, self.__BANK_ACCOUNTS_PATH, country, currency), + return self._api_client.get(self.build_path(self.__VALIDATION_PATH, self.__BANK_ACCOUNTS_PATH, + country, currency), self._sdk_authorization(AuthorizationType.OAUTH), bank_account_field_query) From fccec5daaa302f7b588955e0795522e25bc214d7 Mon Sep 17 00:00:00 2001 From: david ruiz Date: Fri, 6 Nov 2026 12:15:07 +0100 Subject: [PATCH 4/4] Pypy 3.8 fixes --- checkout_sdk/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/checkout_sdk/client.py b/checkout_sdk/client.py index 9682bfb6..306a9344 100644 --- a/checkout_sdk/client.py +++ b/checkout_sdk/client.py @@ -61,7 +61,8 @@ def _encode_path_segment(value): if not issubclass(type(value), str): raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) # Exact str copy of the characters, so no overridden method of a str subclass takes part. - value = str.__str__(value) + # A full slice copies on CPython and PyPy alike; str.__str__ keeps the subclass on PyPy. + value = str.__getitem__(value, slice(None)) if _FORBIDDEN_SEGMENT_CHARACTERS.search(value): raise CheckoutArgumentException(_INVALID_SEGMENT_MESSAGE) try: