From a19c30456d77db4a7998c48f6db628fc2ec7c933 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Tue, 24 Mar 2026 11:57:44 +0100 Subject: [PATCH 1/3] PTHMINT-101: Add dev-only custom base URL support Allow passing a custom API base_url for local development while preventing custom URLs in non-dev builds. Client now accepts base_url and resolves it via _resolve_base_url (uses MSP_SDK_BUILD_PROFILE, MSP_SDK_ALLOW_CUSTOM_BASE_URL, MSP_SDK_CUSTOM_BASE_URL); _normalize_base_url validates/normalizes the URL. Sdk forwards base_url to Client. Added unit tests covering default test/live URLs, dev-only custom URL acceptance, env-provided URL, and blocking in release/when flag disabled. README updated with usage and guardrails. --- README.md | 28 +++++++ src/multisafepay/client/client.py | 51 +++++++++++- src/multisafepay/sdk.py | 4 + .../unit/client/test_unit_client.py | 81 +++++++++++++++++++ tests/multisafepay/unit/test_unit_sdk.py | 66 +++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/multisafepay/unit/test_unit_sdk.py diff --git a/README.md b/README.md index f49eae8..9b35aa8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,34 @@ from multisafepay import Sdk multisafepay_sdk: Sdk = Sdk(api_key='', is_production=True) ``` +### Development-only custom base URL override + +By default, the SDK only targets: + +- `test`: `https://testapi.multisafepay.com/v1/` +- `live`: `https://api.multisafepay.com/v1/` + +For local development, a custom base URL can be enabled with strict guardrails: + +```bash +export MSP_SDK_BUILD_PROFILE=dev +export MSP_SDK_ALLOW_CUSTOM_BASE_URL=1 +``` + +Then pass `base_url`: + +```python +from multisafepay import Sdk + +sdk = Sdk( + api_key="", + is_production=False, + base_url="https://dev-api.example.com/v1", +) +``` + +In any non-dev profile (including default `release`), custom base URLs are blocked and the SDK will only use `test/live` URLs. + ## Examples Go to the folder `examples` to see how to use the SDK. diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index 37805b6..d3bb2b5 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -7,7 +7,9 @@ """HTTP client module for making API requests to MultiSafepay services.""" +import os from typing import Any, Optional +from urllib.parse import urlparse from multisafepay.api.base.response.api_response import ApiResponse from multisafepay.transport import HTTPTransport, RequestsTransport @@ -33,6 +35,9 @@ class Client: LIVE_URL = "https://api.multisafepay.com/v1/" TEST_URL = "https://testapi.multisafepay.com/v1/" + BUILD_PROFILE_ENV = "MSP_SDK_BUILD_PROFILE" + CUSTOM_BASE_URL_ENV = "MSP_SDK_CUSTOM_BASE_URL" + ALLOW_CUSTOM_BASE_URL_ENV = "MSP_SDK_ALLOW_CUSTOM_BASE_URL" METHOD_POST = "POST" METHOD_GET = "GET" @@ -45,6 +50,7 @@ def __init__( is_production: bool, transport: Optional[HTTPTransport] = None, locale: str = "en_US", + base_url: Optional[str] = None, ) -> None: """ Initialize the Client. @@ -56,13 +62,56 @@ def __init__( transport (Optional[HTTPTransport], optional): Custom HTTP transport implementation. Defaults to RequestsTransport if not provided. locale (str, optional): Locale for the requests. Defaults to "en_US". + base_url (Optional[str], optional): Custom API base URL. + Only allowed when running with `MSP_SDK_BUILD_PROFILE=dev` + and `MSP_SDK_ALLOW_CUSTOM_BASE_URL=1`. """ self.api_key = ApiKey(api_key=api_key) - self.url = self.LIVE_URL if is_production else self.TEST_URL + self.url = self._resolve_base_url( + is_production=is_production, + explicit_base_url=base_url, + ) self.transport = transport or RequestsTransport() self.locale = locale + def _resolve_base_url( + self: "Client", + is_production: bool, + explicit_base_url: Optional[str], + ) -> str: + profile = os.getenv(self.BUILD_PROFILE_ENV, "release").strip().lower() + if profile != "dev": + profile = "release" + + env_base_url = os.getenv(self.CUSTOM_BASE_URL_ENV, "").strip() + requested_base_url = (explicit_base_url or env_base_url or "").strip() + + if not requested_base_url: + return self.LIVE_URL if is_production else self.TEST_URL + + allow_custom = os.getenv( + self.ALLOW_CUSTOM_BASE_URL_ENV, + "0", + ).strip().lower() in {"1", "true", "yes"} + + if profile != "dev" or not allow_custom: + msg = ( + "Custom base URL is only allowed in dev profile with " + "MSP_SDK_ALLOW_CUSTOM_BASE_URL enabled." + ) + raise ValueError(msg) + + return self._normalize_base_url(requested_base_url) + + @staticmethod + def _normalize_base_url(base_url: str) -> str: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Invalid custom base URL.") + + return base_url.rstrip("/") + "/" + def create_get_request( self: "Client", endpoint: str, diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index 1efb44a..b22dc3b 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -42,6 +42,7 @@ def __init__( is_production: bool, transport: Optional[HTTPTransport] = None, locale: str = "en_US", + base_url: Optional[str] = None, ) -> None: """ Initialize the SDK with the provided configuration. @@ -57,6 +58,8 @@ def __init__( If not provided, defaults to RequestsTransport, by default None. locale : str, optional The locale to use for requests, by default "en_US". + base_url : Optional[str], optional + Custom API base URL (dev-only guardrails apply), by default None. """ self.client = Client( @@ -64,6 +67,7 @@ def __init__( is_production, transport, locale, + base_url, ) self.recurring_manager = RecurringManager(self.client) diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 1be127a..25ce93c 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -35,3 +35,84 @@ def test_initializes_with_custom_requests_session_via_transport(): assert client.transport is transport assert client.transport.session is session session.close() + + +def test_defaults_to_test_url(monkeypatch: pytest.MonkeyPatch): + """Test that client defaults to test URL when not in production.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + + client = Client(api_key="mock_api_key", is_production=False) + assert client.url == Client.TEST_URL + + +def test_defaults_to_live_url(monkeypatch: pytest.MonkeyPatch): + """Test that client defaults to live URL when in production mode.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + + client = Client(api_key="mock_api_key", is_production=True) + assert client.url == Client.LIVE_URL + + +def test_allows_custom_base_url_only_in_dev_profile( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL is allowed only in dev profile with flag enabled.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + client = Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1", + ) + assert client.url == "https://dev-api.multisafepay.test/v1/" + + +def test_blocks_custom_base_url_in_release_profile( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL is blocked in release profile.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "release") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1", + ) + + +def test_blocks_custom_base_url_when_flag_disabled( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL is blocked when the enable flag is disabled.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "0") + + with pytest.raises(ValueError, match="Custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1", + ) + + +def test_allows_custom_base_url_from_env_in_dev_profile( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL can be provided via environment in dev profile.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + monkeypatch.setenv( + "MSP_SDK_CUSTOM_BASE_URL", + "https://dev-api.multisafepay.test/v1", + ) + + client = Client(api_key="mock_api_key", is_production=False) + + assert client.url == "https://dev-api.multisafepay.test/v1/" diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py new file mode 100644 index 0000000..038338e --- /dev/null +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -0,0 +1,66 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Unit tests for SDK-level environment/base URL guardrails.""" + +import pytest + +from multisafepay import Sdk +from multisafepay.client.client import Client + + +def test_sdk_uses_test_url_by_default(monkeypatch: pytest.MonkeyPatch): + """Test that SDK client defaults to test URL when not in production.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + + sdk = Sdk(api_key="mock_api_key", is_production=False) + + assert sdk.get_client().url == Client.TEST_URL + + +def test_sdk_uses_live_url_in_production(monkeypatch: pytest.MonkeyPatch): + """Test that SDK client uses live URL in production mode.""" + monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False) + monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False) + + sdk = Sdk(api_key="mock_api_key", is_production=True) + + assert sdk.get_client().url == Client.LIVE_URL + + +def test_sdk_allows_custom_base_url_in_dev_when_enabled( + monkeypatch: pytest.MonkeyPatch, +): + """Test that SDK allows custom base URL in dev profile when enabled.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + sdk = Sdk( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1", + ) + + assert sdk.get_client().url == "https://dev-api.multisafepay.test/v1/" + + +def test_sdk_blocks_custom_base_url_in_release( + monkeypatch: pytest.MonkeyPatch, +): + """Test that SDK blocks custom base URL in release profile.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "release") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Custom base URL"): + Sdk( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1", + ) From 46639114fb9922753c0c1909817f67fa41fc5f89 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Mon, 6 Apr 2026 13:30:29 +0200 Subject: [PATCH 2/3] PTHMINT-101: Harden custom base URL validation Reject invalid custom base URLs with query strings, fragments, or malformed hosts. Add unit coverage for invalid URL cases and document environment-variable precedence for custom base URLs. --- README.md | 15 ++++- src/multisafepay/client/client.py | 8 ++- .../unit/client/test_unit_client.py | 60 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b35aa8..c70b012 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,15 @@ export MSP_SDK_BUILD_PROFILE=dev export MSP_SDK_ALLOW_CUSTOM_BASE_URL=1 ``` -Then pass `base_url`: +You can provide the custom base URL either via environment variable or via the SDK argument. + +Environment variable option: + +```bash +export MSP_SDK_CUSTOM_BASE_URL="https://dev-api.example.com/v1" +``` + +SDK argument option: ```python from multisafepay import Sdk @@ -101,6 +109,11 @@ sdk = Sdk( ) ``` +Precedence when both are set: + +- The explicit SDK argument base_url takes priority. +- If base_url is not passed, MSP_SDK_CUSTOM_BASE_URL is used. + In any non-dev profile (including default `release`), custom base URLs are blocked and the SDK will only use `test/live` URLs. ## Examples diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index d3bb2b5..f752224 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -110,7 +110,13 @@ def _normalize_base_url(base_url: str) -> str: if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError("Invalid custom base URL.") - return base_url.rstrip("/") + "/" + if parsed.query or parsed.fragment: + raise ValueError("Invalid custom base URL.") + + path = parsed.path.rstrip("/") + path = "/" if not path else path + "/" + + return f"{parsed.scheme}://{parsed.netloc}{path}" def create_get_request( self: "Client", diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 25ce93c..886f4ac 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -116,3 +116,63 @@ def test_allows_custom_base_url_from_env_in_dev_profile( client = Client(api_key="mock_api_key", is_production=False) assert client.url == "https://dev-api.multisafepay.test/v1/" + + +def test_rejects_custom_base_url_with_query( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL rejects query strings.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Invalid custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1?foo=bar", + ) + + +def test_rejects_custom_base_url_with_fragment( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL rejects fragments.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Invalid custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1#section", + ) + + +def test_rejects_custom_base_url_without_scheme( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL rejects missing scheme.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Invalid custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="dev-api.multisafepay.test/v1", + ) + + +def test_rejects_custom_base_url_without_netloc( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL rejects missing netloc.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Invalid custom base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https:///v1", + ) From f4708bdf3f63f00234b7aff3ade2c14189374529 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Tue, 7 Apr 2026 09:47:06 +0200 Subject: [PATCH 3/3] PTHMINT-101: Tighten base URL validation and update tests Improve Client._normalize_base_url by rejecting URLs that include path parameters (parsed.params) and standardize the error message to "Invalid base URL.". Update unit tests to expect the new message, add a test ensuring an explicit base_url argument takes precedence over environment settings, and add a test that rejects base URLs containing params. --- src/multisafepay/client/client.py | 6 +-- .../unit/client/test_unit_client.py | 43 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index f752224..d6d5e00 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -108,10 +108,10 @@ def _resolve_base_url( def _normalize_base_url(base_url: str) -> str: parsed = urlparse(base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: - raise ValueError("Invalid custom base URL.") + raise ValueError("Invalid base URL.") - if parsed.query or parsed.fragment: - raise ValueError("Invalid custom base URL.") + if parsed.params or parsed.query or parsed.fragment: + raise ValueError("Invalid base URL.") path = parsed.path.rstrip("/") path = "/" if not path else path + "/" diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 886f4ac..92af510 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -118,6 +118,26 @@ def test_allows_custom_base_url_from_env_in_dev_profile( assert client.url == "https://dev-api.multisafepay.test/v1/" +def test_explicit_base_url_takes_precedence_over_env( + monkeypatch: pytest.MonkeyPatch, +): + """Test that an explicit base URL overrides the environment value.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + monkeypatch.setenv( + "MSP_SDK_CUSTOM_BASE_URL", + "https://env-api.multisafepay.test/v1", + ) + + client = Client( + api_key="mock_api_key", + is_production=False, + base_url="https://explicit-api.multisafepay.test/v1", + ) + + assert client.url == "https://explicit-api.multisafepay.test/v1/" + + def test_rejects_custom_base_url_with_query( monkeypatch: pytest.MonkeyPatch, ): @@ -125,7 +145,7 @@ def test_rejects_custom_base_url_with_query( monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") - with pytest.raises(ValueError, match="Invalid custom base URL"): + with pytest.raises(ValueError, match="Invalid base URL"): Client( api_key="mock_api_key", is_production=False, @@ -140,7 +160,7 @@ def test_rejects_custom_base_url_with_fragment( monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") - with pytest.raises(ValueError, match="Invalid custom base URL"): + with pytest.raises(ValueError, match="Invalid base URL"): Client( api_key="mock_api_key", is_production=False, @@ -148,6 +168,21 @@ def test_rejects_custom_base_url_with_fragment( ) +def test_rejects_custom_base_url_with_params( + monkeypatch: pytest.MonkeyPatch, +): + """Test that custom base URL rejects path parameters.""" + monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") + monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") + + with pytest.raises(ValueError, match="Invalid base URL"): + Client( + api_key="mock_api_key", + is_production=False, + base_url="https://dev-api.multisafepay.test/v1;foo", + ) + + def test_rejects_custom_base_url_without_scheme( monkeypatch: pytest.MonkeyPatch, ): @@ -155,7 +190,7 @@ def test_rejects_custom_base_url_without_scheme( monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") - with pytest.raises(ValueError, match="Invalid custom base URL"): + with pytest.raises(ValueError, match="Invalid base URL"): Client( api_key="mock_api_key", is_production=False, @@ -170,7 +205,7 @@ def test_rejects_custom_base_url_without_netloc( monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev") monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1") - with pytest.raises(ValueError, match="Invalid custom base URL"): + with pytest.raises(ValueError, match="Invalid base URL"): Client( api_key="mock_api_key", is_production=False,