From a9e9c5d63243d0aef18e9bb5c484cc5a68d5a58c Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 5 Oct 2026 15:24:41 +0200 Subject: [PATCH 1/5] fix(PYT-44): harden payment checks and sensitive data handling * fix(PYT-44): harden payment checks and sensitive data handling - Require explicit paid status in the low-level payment predicate - Pass curl credentials and request bodies through escaped stdin config - Omit log bodies when masking cannot safely process them - Cover security regressions and preserve high-level success flags * tests(PYT-44): align security tests with source modules - Move regression tests into the existing HTTP, model and service test files - Share the loopback fixture in unit conftest and remove standalone test modules --- buckaroo/http/client.py | 42 ++------ buckaroo/http/strategies/curl_strategy.py | 46 ++++---- buckaroo/observers/logging_observer.py | 6 +- tests/unit/conftest.py | 33 ++++++ .../http/strategies/test_curl_strategy.py | 102 +++++++----------- tests/unit/http/test_response.py | 97 +++++++---------- tests/unit/models/test_payment_response.py | 9 ++ tests/unit/observers/test_logging_observer.py | 50 ++++++--- .../services/test_hosted_fields_service.py | 21 ++++ 9 files changed, 218 insertions(+), 188 deletions(-) diff --git a/buckaroo/http/client.py b/buckaroo/http/client.py index 17f0f1c..51eb021 100644 --- a/buckaroo/http/client.py +++ b/buckaroo/http/client.py @@ -244,27 +244,7 @@ def json(self) -> Dict[str, Any]: def is_successful_payment(self) -> bool: """Check if the payment was successful based on Buckaroo response.""" - if not self.success: - return False - - # Check Buckaroo-specific success indicators - if self._data and "Status" in self._data: - # Buckaroo status codes for successful payments - success_statuses = [190, 490, 491, 492, 790, 791, 792, 793] - status = self._data.get("Status", {}) - if status and "Code" in status: - code = status.get("Code") - # Handle nested Code structure - if isinstance(code, dict): - actual_code = code.get("Code") - elif isinstance(code, int): - actual_code = code - else: - actual_code = None - - return actual_code in success_statuses if actual_code is not None else False - - return self.success + return self.success and self.get_status_code() == 190 def get_payment_key(self) -> Optional[str]: """Get the payment key from the response.""" @@ -287,25 +267,15 @@ def get_transaction_key(self) -> Optional[str]: def get_status_code(self) -> Optional[int]: """Get the Buckaroo status code.""" - if not self._data: + if not isinstance(self._data, dict): return None - - status = self._data.get("Status", {}) - if not status: + status = self._data.get("Status") + if not isinstance(status, dict): return None - code = status.get("Code") - if code is None: - return None - - # Handle nested Code structure: {"Code": 490, "Description": "Failed"} if isinstance(code, dict): - return code.get("Code") - # Handle simple integer code - elif isinstance(code, int): - return code - - return None + code = code.get("Code") + return code if type(code) is int else None def get_status_message(self) -> Optional[str]: """Get the Buckaroo status message.""" diff --git a/buckaroo/http/strategies/curl_strategy.py b/buckaroo/http/strategies/curl_strategy.py index b20c8f5..1d0dd94 100644 --- a/buckaroo/http/strategies/curl_strategy.py +++ b/buckaroo/http/strategies/curl_strategy.py @@ -54,12 +54,18 @@ def request( cmd = self._build_curl_command( method=method, url=url, - headers=headers, - data=data, timeout=timeout or self._timeout, verify_ssl=verify_ssl, ) + # Keep credentials and payloads off the process command line. + config_lines = [] + for key, value in {**self._default_headers, **(headers or {})}.items(): + config_lines.append(f"header = {self._quote_config_value(f'{key}: {value}')}") + if data and method.upper() in ["POST", "PUT", "PATCH"]: + config_lines.append(f"data-raw = {self._quote_config_value(data)}") + config_input = "\n".join(config_lines) + "\n" + # Execute curl with retry logic if self._retry_attempts <= 0: raise Exception("Request failed after all retry attempts") @@ -68,8 +74,10 @@ def request( try: result = subprocess.run( cmd, + input=config_input, capture_output=True, text=True, + encoding="utf-8", timeout=timeout or self._timeout, check=False, # Don't raise on non-zero exit codes ) @@ -95,8 +103,6 @@ def _build_curl_command( self, method: str, url: str, - headers: Optional[Dict[str, str]] = None, - data: Optional[str] = None, timeout: int = 30, verify_ssl: bool = True, ) -> List[str]: @@ -106,8 +112,6 @@ def _build_curl_command( Args: method: HTTP method url: Request URL - headers: Request headers - data: Request body data timeout: Request timeout verify_ssl: Whether to verify SSL @@ -116,6 +120,8 @@ def _build_curl_command( """ cmd = [ "curl", + "--config", + "-", "-X", method.upper(), "--location", # Follow redirects @@ -131,23 +137,27 @@ def _build_curl_command( if not verify_ssl: cmd.extend(["--insecure"]) - # Add headers - all_headers = {**self._default_headers} - if headers: - all_headers.update(headers) - - for key, value in all_headers.items(): - cmd.extend(["-H", f"{key}: {value}"]) - - # Add data for POST/PUT requests - if data and method.upper() in ["POST", "PUT", "PATCH"]: - cmd.extend(["--data", data]) - # Add URL last cmd.append(url) return cmd + @staticmethod + def _quote_config_value(value: str) -> str: + """Quote a literal curl config value without allowing new options.""" + if "\0" in value: + raise ValueError("curl input cannot contain NUL characters") + for original, escaped in ( + ("\\", "\\\\"), + ('"', '\\"'), + ("\n", "\\n"), + ("\r", "\\r"), + ("\t", "\\t"), + ("\v", "\\v"), + ): + value = value.replace(original, escaped) + return f'"{value}"' + def _parse_curl_output(self, result: subprocess.CompletedProcess) -> HttpResponse: """ Parse curl output into HttpResponse object. diff --git a/buckaroo/observers/logging_observer.py b/buckaroo/observers/logging_observer.py index 46e669e..8c3de1a 100644 --- a/buckaroo/observers/logging_observer.py +++ b/buckaroo/observers/logging_observer.py @@ -165,12 +165,14 @@ def _format_json(self, data: Any) -> str: try: data = json.loads(data) except json.JSONDecodeError: - return data + return "***BODY_OMITTED***" if self.config.mask_sensitive_data else data masked_data = self._mask_sensitive_data(data) + if self.config.mask_sensitive_data: + return json.dumps(masked_data, indent=2) return json.dumps(masked_data, indent=2, default=str) except Exception: - return str(data) + return "***BODY_OMITTED***" if self.config.mask_sensitive_data else str(data) def log_request( self, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 198fdae..f5e8d28 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,5 +1,8 @@ """Shared fixtures for unit tests.""" +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import pytest from buckaroo._buckaroo_client import BuckarooClient @@ -40,3 +43,33 @@ def env_credentials(monkeypatch): monkeypatch.setenv("BUCKAROO_STORE_KEY", "sk") monkeypatch.setenv("BUCKAROO_SECRET_KEY", "ss") return monkeypatch + + +@pytest.fixture +def local_endpoint(): + received = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + received.append((self.command, dict(self.headers), body)) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"access_token":"synthetic-token"}') + + do_PUT = do_POST + do_PATCH = do_POST + do_GET = do_POST + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/token", received + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/unit/http/strategies/test_curl_strategy.py b/tests/unit/http/strategies/test_curl_strategy.py index e04c0c2..dff8b6c 100644 --- a/tests/unit/http/strategies/test_curl_strategy.py +++ b/tests/unit/http/strategies/test_curl_strategy.py @@ -1,5 +1,6 @@ """Unit tests for buckaroo.http.strategies.curl_strategy.""" +import shutil import subprocess from types import SimpleNamespace from unittest.mock import patch @@ -94,68 +95,6 @@ def test_omits_insecure_when_verify_ssl_true(self): assert "--insecure" not in cmd - def test_merges_default_headers_with_per_call_headers(self): - strategy = CurlStrategy() - strategy.configure(default_headers={"X-Default": "d", "X-Shared": "default"}) - - cmd = strategy._build_curl_command( - method="GET", - url="https://x", - headers={"X-Call": "c", "X-Shared": "perCall"}, - ) - - header_values = [cmd[i + 1] for i, arg in enumerate(cmd) if arg == "-H"] - assert "X-Default: d" in header_values - assert "X-Call: c" in header_values - # per-call wins over default for overlapping key - assert "X-Shared: perCall" in header_values - assert "X-Shared: default" not in header_values - - def test_headers_omitted_when_neither_default_nor_per_call_provided(self): - strategy = CurlStrategy() - - cmd = strategy._build_curl_command(method="GET", url="https://x") - - assert "-H" not in cmd - - @pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) - def test_data_attached_for_write_methods(self, method): - strategy = CurlStrategy() - - cmd = strategy._build_curl_command(method=method, url="https://x", data='{"a":1}') - - assert "--data" in cmd - assert cmd[cmd.index("--data") + 1] == '{"a":1}' - - @pytest.mark.parametrize("method", ["GET", "DELETE"]) - def test_data_omitted_for_read_methods(self, method): - strategy = CurlStrategy() - - cmd = strategy._build_curl_command(method=method, url="https://x", data='{"a":1}') - - assert "--data" not in cmd - - def test_data_omitted_when_data_is_none_even_for_post(self): - strategy = CurlStrategy() - - cmd = strategy._build_curl_command(method="POST", url="https://x", data=None) - - assert "--data" not in cmd - - def test_url_is_last_argument(self): - strategy = CurlStrategy() - strategy.configure(default_headers={"X-Default": "d"}) - - cmd = strategy._build_curl_command( - method="POST", - url="https://api.test/path", - headers={"X-Call": "c"}, - data="body", - verify_ssl=False, - ) - - assert cmd[-1] == "https://api.test/path" - def test_lowercase_method_is_uppercased(self): strategy = CurlStrategy() @@ -344,7 +283,8 @@ def test_returns_parsed_response_on_first_success(self): assert cmd[0] == "curl" assert cmd[-1] == "https://api.test/path" assert "--insecure" in cmd - assert "--data" in cmd + assert "--data" not in cmd + assert 'data-raw = "payload"' in kwargs["input"] assert kwargs["timeout"] == 10 assert kwargs["capture_output"] is True assert kwargs["text"] is True @@ -452,3 +392,39 @@ def test_returns_false_when_curl_missing(self): class TestGetName: def test_returns_curl(self): assert CurlStrategy().get_name() == "curl" + + +@pytest.mark.skipif(not shutil.which("curl"), reason="curl is required") +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH"]) +def test_private_headers_and_body_reach_server_unchanged(local_endpoint, monkeypatch, method): + url, received = local_endpoint + strategy = CurlStrategy() + strategy.configure(default_headers={"X-Default": "synthetic-default", "X-Shared": "default"}) + body = '@not-a-file\n"\nurl = "http://127.0.0.1:1/injected"\n# café\t\\end\r\n' + authorization = "hmac synthetic:signature:nonce:timestamp" + header_value = 'synthetic-"quoted"-\\value' + real_run = subprocess.run + + def inspect_command(cmd, **kwargs): + for secret in (body, authorization, header_value, "synthetic-default"): + assert secret not in " ".join(cmd) + assert kwargs.get("shell", False) is False + return real_run(cmd, **kwargs) + + monkeypatch.setattr(subprocess, "run", inspect_command) + response = strategy.request( + method, + url, + headers={"Authorization": authorization, "X-Test": header_value, "X-Shared": "per-call"}, + data=body, + timeout=5, + ) + assert response.status_code == 200 + assert len(received) == 1 + actual_method, headers, actual_body = received[0] + assert actual_method == method + assert actual_body == body.encode("utf-8") + assert headers["Authorization"] == authorization + assert headers["X-Test"] == header_value + assert headers["X-Default"] == "synthetic-default" + assert headers["X-Shared"] == "per-call" diff --git a/tests/unit/http/test_response.py b/tests/unit/http/test_response.py index c151fcb..2760561 100644 --- a/tests/unit/http/test_response.py +++ b/tests/unit/http/test_response.py @@ -5,6 +5,8 @@ directly — no HTTP client, no mocks. """ +import json + import pytest from buckaroo.http.client import BuckarooResponse @@ -86,62 +88,6 @@ def test_headers_pass_through(self): assert response.headers == headers -class TestIsSuccessfulPayment: - def test_returns_false_when_http_failed(self): - response = BuckarooResponse( - make_response(status_code=500, text='{"Status": {"Code": 190}}') - ) - - assert response.is_successful_payment() is False - - @pytest.mark.parametrize("code", [190, 490, 491, 492, 790, 791, 792, 793]) - def test_true_for_each_buckaroo_success_code(self, code): - response = BuckarooResponse(make_response(text=f'{{"Status": {{"Code": {code}}}}}')) - - assert response.is_successful_payment() is True - - def test_false_for_non_success_buckaroo_code(self): - response = BuckarooResponse(make_response(text='{"Status": {"Code": 491000}}')) - - assert response.is_successful_payment() is False - - def test_handles_nested_code_dict_shape(self): - response = BuckarooResponse( - make_response(text='{"Status": {"Code": {"Code": 190, "Description": "Success"}}}') - ) - - assert response.is_successful_payment() is True - - def test_returns_success_when_no_status_field(self): - # HTTP 2xx but no "Status" in body — falls through to self.success. - response = BuckarooResponse(make_response(text='{"Other": "field"}')) - - assert response.is_successful_payment() is True - - def test_true_when_status_code_missing_from_status(self): - response = BuckarooResponse(make_response(text='{"Status": {"Other": 1}}')) - - # Status present but no "Code" key — falls through to self.success. - assert response.is_successful_payment() is True - - def test_false_when_code_is_unknown_type(self): - response = BuckarooResponse(make_response(text='{"Status": {"Code": "oops"}}')) - - assert response.is_successful_payment() is False - - def test_true_when_status_is_falsy(self): - # Status present but falsy — skips the Buckaroo-code branch. - response = BuckarooResponse(make_response(text='{"Status": null}')) - - assert response.is_successful_payment() is True - - def test_true_when_data_is_empty_but_http_ok(self): - # No _data at all -> falls through to self.success. - response = BuckarooResponse(make_response(text="")) - - assert response.is_successful_payment() is True - - class TestGetStatusCode: def test_returns_simple_int_code(self): response = BuckarooResponse(make_response(text='{"Status": {"Code": 190}}')) @@ -339,3 +285,42 @@ def test_includes_status_success_data_and_headers(self): "data": {"Key": "k"}, "headers": headers, } + + +@pytest.mark.parametrize( + "status,expected", + [ + ({"Code": code}, code == 190) + for code in [190, 490, 491, 492, 690, 790, 791, 792, 793, 794, 890, 891, 999] + ] + + [({"Code": {"Code": code}}, code == 190) for code in [190, 490, 791]] + + [ + (status, False) + for status in [ + None, + {}, + [], + "invalid", + 190, + {"Code": None}, + {"Code": []}, + {"Code": "190"}, + {"Code": 190.0}, + ] + ], +) +def test_payment_success_requires_explicit_paid_status(status, expected): + data = {"Status": status, "RequiredAction": {"RedirectURL": "https://example.invalid/pay"}} + raw = HttpResponse(200, {}, json.dumps(data), True) + assert BuckarooResponse(raw).is_successful_payment() is expected + + +@pytest.mark.parametrize("body", ["", "{}", "null", "[]", '"invalid"']) +def test_missing_payment_status_is_not_paid(body): + assert BuckarooResponse(HttpResponse(200, {}, body, True)).is_successful_payment() is False + + +def test_http_failure_is_not_payment_success(): + raw = HttpResponse(500, {}, '{"Status":{"Code":190}}', False) + response = BuckarooResponse(raw) + assert response.is_successful_payment() is False diff --git a/tests/unit/models/test_payment_response.py b/tests/unit/models/test_payment_response.py index 10aa80b..5d265c6 100644 --- a/tests/unit/models/test_payment_response.py +++ b/tests/unit/models/test_payment_response.py @@ -673,3 +673,12 @@ def test_has_error_false_when_request_errors_is_scalar(): assert response.has_error() is False assert response.get_first_error() == {} assert response.get_some_error() == "" + + +@pytest.mark.parametrize("flag", [True, False]) +def test_high_level_response_preserves_explicit_success_flag(flag): + # The high-level wrapper accepts a caller-supplied verdict independently + # of the transport response; keep that existing contract. + for envelope in [{}, {"status_code": 200, "success": True, "data": {"Status": {"Code": 190}}}]: + response = PaymentResponse({**envelope, "is_successful_payment": flag}) + assert response.is_successful() is flag diff --git a/tests/unit/observers/test_logging_observer.py b/tests/unit/observers/test_logging_observer.py index 45dd62c..53952f1 100644 --- a/tests/unit/observers/test_logging_observer.py +++ b/tests/unit/observers/test_logging_observer.py @@ -170,9 +170,9 @@ def test_format_json_parses_json_string_and_masks(): assert parsed["amount"] == 10 -def test_format_json_non_json_string_returned_verbatim(): +def test_format_json_non_json_string_is_omitted(): obs = _observer() - assert obs._format_json("not json at all") == "not json at all" + assert obs._format_json("not json at all") == "***BODY_OMITTED***" def test_format_json_dict_input_produces_masked_json(): @@ -183,19 +183,21 @@ def test_format_json_dict_input_produces_masked_json(): assert parsed["currency"] == "EUR" -def test_format_json_non_serialisable_falls_back_to_str(): - class NotJSON: - def __repr__(self): - return "" +def test_format_json_non_serialisable_is_omitted(): + obs = _observer() + assert obs._format_json({object(): "value"}) == "***BODY_OMITTED***" + + +def test_opaque_object_body_is_omitted(caplog): + class OpaqueBody: + def __str__(self): + return "synthetic-secret" obs = _observer() - # json.dumps handles most things via default=str; force a failure by - # triggering an exception path — a dict with a non-serialisable key - # (keys must be str/int/float/bool/None) raises TypeError. - weird = {object(): "value"} - result = obs._format_json(weird) - assert isinstance(result, str) - assert result == str(weird) + with caplog.at_level(logging.INFO, logger="buckaroo_sdk"): + obs.log_response(200, body=OpaqueBody()) + assert "synthetic-secret" not in caplog.text + assert "***BODY_OMITTED***" in caplog.text # --- Case-insensitive substring matching --- @@ -692,3 +694,25 @@ def test_child_log_info_family_merges_context(caplog, method, expected_level): assert rec.levelno == expected_level assert "abc" in rec.message assert "hello" in rec.message + + +@pytest.mark.parametrize( + "body", + [ + "token=synthetic-secret&iban=NL00SYNTHETIC", + '{"token":"synthetic-secret"', + {1: "bad key", "token": "synthetic-secret"}, + ], +) +@pytest.mark.parametrize("direction", ["request", "response"]) +@pytest.mark.parametrize("mask", [True, False]) +def test_log_body_failure_paths_respect_masking(body, direction, mask, caplog): + observer = _observer(mask=mask) + with caplog.at_level(logging.INFO, logger="buckaroo_sdk"): + if direction == "request": + observer.log_request("POST", "https://example.invalid", body=body) + else: + observer.log_response(200, body=body) + assert ("synthetic-secret" in caplog.text) is (not mask) + if mask: + assert "***BODY_OMITTED***" in caplog.text diff --git a/tests/unit/services/test_hosted_fields_service.py b/tests/unit/services/test_hosted_fields_service.py index ab63942..f6c34a0 100644 --- a/tests/unit/services/test_hosted_fields_service.py +++ b/tests/unit/services/test_hosted_fields_service.py @@ -3,9 +3,12 @@ from __future__ import annotations import base64 +import shutil +import subprocess import pytest +from buckaroo.http.strategies import CurlStrategy from buckaroo.exceptions._buckaroo_error import BuckarooError from buckaroo.services.hosted_fields_service import HostedFieldsService from tests.support.mock_request import BuckarooMockRequest @@ -184,3 +187,21 @@ def test_rejects_empty_client_id(self, mock_strategy): def test_rejects_empty_client_secret(self, mock_strategy): with pytest.raises(ValueError): HostedFieldsService(client_id="x", client_secret="", http_strategy=mock_strategy) + + +@pytest.mark.skipif(not shutil.which("curl"), reason="curl is required") +def test_hosted_fields_basic_credentials_are_private(local_endpoint, monkeypatch): + url, received = local_endpoint + service = HostedFieldsService("synthetic-id", "synthetic-secret", http_strategy=CurlStrategy()) + monkeypatch.setattr(service, "OAUTH_TOKEN_URL", url) + authorization = "Basic " + base64.b64encode(b"synthetic-id:synthetic-secret").decode() + real_run = subprocess.run + + def inspect_command(cmd, **kwargs): + assert authorization not in " ".join(cmd) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(subprocess, "run", inspect_command) + assert service.get_token() == {"access_token": "synthetic-token"} + assert received[0][1]["Authorization"] == authorization + assert received[0][2] == b"scope=hostedfields%3Asave&grant_type=client_credentials" From 09e246bfb98947bda912c5f82e2db0d1e333b7a2 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 12 Oct 2026 13:42:44 +0200 Subject: [PATCH 2/5] BTI-1351: Remove GoSettle as a payment method (Servicecode: knaken) (#28) * chore(BTI-1351): remove GoSettle changelog reference - Remove the final deprecated payment method reference * refactor(BTI-1351): remove GoSettle payment method - Remove the Knaken builder and factory registration - Remove its feature and unit tests --- CHANGELOG.md | 1 - buckaroo/builders/payments/knaken_builder.py | 15 ---- buckaroo/factories/payment_method_factory.py | 2 - tests/feature/payments/test_knaken.py | 14 --- .../builders/payments/test_knaken_builder.py | 86 ------------------- 5 files changed, 118 deletions(-) delete mode 100644 buckaroo/builders/payments/knaken_builder.py delete mode 100644 tests/feature/payments/test_knaken.py delete mode 100644 tests/unit/builders/payments/test_knaken_builder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad0dc6..a81877d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,6 @@ All notable changes to this project will be documented in this file. - BA-1003 Add payment method: Creditcards - BA-1004 Add payment method: EPS - BA-1005 Add payment method: Giftcards -- BA-1006 Add payment method: goSettle - BA-1007 Add payment method: Google Pay - BA-1008 Add payment method: External Payment - BA-1009 Add payment method: iDEAL | Wero diff --git a/buckaroo/builders/payments/knaken_builder.py b/buckaroo/builders/payments/knaken_builder.py deleted file mode 100644 index 7a4ce97..0000000 --- a/buckaroo/builders/payments/knaken_builder.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Dict, Any -from .payment_builder import PaymentBuilder - - -class KnakenBuilder(PaymentBuilder): - """Builder for Knaken payments.""" - - def get_service_name(self) -> str: - """Get the service name for Knaken payments.""" - return "Knaken" - - def get_allowed_service_parameters(self, action: str = "Pay") -> Dict[str, Any]: - """Get the allowed service parameters for Knaken payments based on action.""" - - return {} diff --git a/buckaroo/factories/payment_method_factory.py b/buckaroo/factories/payment_method_factory.py index 23285a9..c25bbbd 100644 --- a/buckaroo/factories/payment_method_factory.py +++ b/buckaroo/factories/payment_method_factory.py @@ -24,7 +24,6 @@ from buckaroo.builders.payments.billink_builder import BillinkBuilder from buckaroo.builders.payments.klarna_builder import KlarnaBuilder from buckaroo.builders.payments.klarnakp_builder import KlarnaKPBuilder -from buckaroo.builders.payments.knaken_builder import KnakenBuilder from buckaroo.builders.payments.przelewy24_builder import Przelewy24Builder from buckaroo.builders.payments.riverty_builder import RivertyBuilder from buckaroo.builders.payments.sepadirectdebit_builder import SepaDirectDebitBuilder @@ -73,7 +72,6 @@ class PaymentMethodFactory(BuilderFactory): "idin": IdinBuilder, "in3": In3Builder, "kbc": KBCBuilder, - "knaken": KnakenBuilder, "klarna": KlarnaBuilder, "klarnakp": KlarnaKPBuilder, "multibanco": MultibancoBuilder, diff --git a/tests/feature/payments/test_knaken.py b/tests/feature/payments/test_knaken.py deleted file mode 100644 index 3d38dd8..0000000 --- a/tests/feature/payments/test_knaken.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Feature test: knaken pay() round-trip through full stack with MockBuckaroo.""" - -from tests.support.helpers import Helpers - - -class TestKnakenFeature: - def test_knaken_pay_returns_pending_with_redirect(self, buckaroo, mock_strategy): - Helpers.assert_pay_returns_pending_with_redirect( - buckaroo, - mock_strategy, - method="knaken", - invoice="INV-KNK-001", - payload_overrides={"description": "Test knaken"}, - ) diff --git a/tests/unit/builders/payments/test_knaken_builder.py b/tests/unit/builders/payments/test_knaken_builder.py deleted file mode 100644 index c2076a2..0000000 --- a/tests/unit/builders/payments/test_knaken_builder.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Unit coverage for :class:`KnakenBuilder`. - -Phase 7.22 — per-builder coverage. KnakenBuilder is a minimal subclass of -:class:`PaymentBuilder` that only overrides :meth:`get_service_name` and -:meth:`get_allowed_service_parameters`; it mixes in no capability classes. -Tests exercise every public surface and pin the allowed-parameter shape for -every action we care about. -""" - -from __future__ import annotations - -import pytest - -from buckaroo.builders.payments.knaken_builder import KnakenBuilder -from buckaroo.builders.payments.payment_builder import PaymentBuilder -from tests.support.builders import populate_required_fields -from tests.support.mock_request import BuckarooMockRequest -from tests.support.recording_mock import recorded_request, wire_recording_http - - -# --------------------------------------------------------------------------- -# Construction - - -def test_construction_wired_to_mock_buckaroo_succeeds(client): - builder = KnakenBuilder(client) - - assert isinstance(builder, KnakenBuilder) - assert isinstance(builder, PaymentBuilder) - - -# --------------------------------------------------------------------------- -# get_service_name - - -def test_get_service_name_returns_knaken(client): - builder = KnakenBuilder(client) - - assert builder.get_service_name() == "Knaken" - - -# --------------------------------------------------------------------------- -# get_allowed_service_parameters — snapshot every supported action - - -@pytest.mark.parametrize( - "action", - ["Pay", "Refund", "PayRemainder", "ExtraInfo", "UnknownAction"], -) -def test_get_allowed_service_parameters_returns_empty_dict_for_every_action(client, action): - builder = KnakenBuilder(client) - - assert builder.get_allowed_service_parameters(action) == {} - - -def test_get_allowed_service_parameters_defaults_to_pay_and_returns_empty(client): - """Covers the ``action: str = "Pay"`` default-argument branch.""" - builder = KnakenBuilder(client) - - assert builder.get_allowed_service_parameters() == {} - - -# --------------------------------------------------------------------------- -# End-to-end pay via MockBuckaroo - - -def test_pay_posts_knaken_service_to_transaction_endpoint_and_parses_response(): - mock, client = wire_recording_http() - mock.queue( - BuckarooMockRequest.json( - "POST", - "*/json/transaction*", - {"Key": "KNK-1", "Status": {"Code": {"Code": 190}}}, - ) - ) - builder = populate_required_fields(KnakenBuilder(client), amount=42.50) - - response = builder.pay(validate=False) - - assert "/json/transaction" in mock.calls[0]["url"].lower() - sent = recorded_request(mock) - service = sent["Services"]["ServiceList"][0] - assert service["Name"] == "Knaken" - assert service["Action"] == "Pay" - assert response.key == "KNK-1" - mock.assert_all_consumed() From 0d7ea9d87b8faeb9e48ec4b26aec23d5e65f13aa Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 26 Oct 2026 11:16:55 +0100 Subject: [PATCH 3/5] chore(BTI-1790): set release version to 1.2.0 - Bump VERSION and user agent to 1.2.0 - Add 1.2.0 changelog entry --- CHANGELOG.md | 4 ++++ buckaroo/_version.py | 2 +- buckaroo/config/buckaroo_config.py | 2 +- tests/unit/config/test_buckaroo_config.py | 2 +- tests/unit/test_package.py | 4 ++-- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81877d..4277044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. ## [Released] +## [1.2.0] +- BTI-1701 Fix unpaid payments reported as successful and secrets exposed by curl and logs +- BTI-1351 Remove GoSettle as payment method (deprecated) + ## [1.1.0] - BTI-16 Add solution: Split Payments - BTI-17 Add solution: PayPerEmail diff --git a/buckaroo/_version.py b/buckaroo/_version.py index 2a3eb2f..848258f 100644 --- a/buckaroo/_version.py +++ b/buckaroo/_version.py @@ -1 +1 @@ -VERSION = "1.1.0" +VERSION = "1.2.0" diff --git a/buckaroo/config/buckaroo_config.py b/buckaroo/config/buckaroo_config.py index 144761c..c209434 100644 --- a/buckaroo/config/buckaroo_config.py +++ b/buckaroo/config/buckaroo_config.py @@ -62,7 +62,7 @@ class BuckarooConfig: logging_enabled: bool = True verify_ssl: bool = True custom_endpoint: Optional[str] = None - user_agent: str = "BuckarooSDK-Python/1.1.0" + user_agent: str = "BuckarooSDK-Python/1.2.0" max_redirects: int = 5 def __post_init__(self): diff --git a/tests/unit/config/test_buckaroo_config.py b/tests/unit/config/test_buckaroo_config.py index 20fa060..d797a26 100644 --- a/tests/unit/config/test_buckaroo_config.py +++ b/tests/unit/config/test_buckaroo_config.py @@ -44,7 +44,7 @@ def test_defaults(): assert cfg.logging_enabled is True assert cfg.verify_ssl is True assert cfg.custom_endpoint is None - assert cfg.user_agent == "BuckarooSDK-Python/1.1.0" + assert cfg.user_agent == "BuckarooSDK-Python/1.2.0" assert cfg.max_redirects == 5 diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index a0f74fa..f035340 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -9,7 +9,7 @@ def test_version_attribute_matches_version_module(): from buckaroo import _version assert buckaroo.__version__ == _version.VERSION - assert buckaroo.__version__ == "1.1.0" + assert buckaroo.__version__ == "1.2.0" def test_public_api_reexports(): @@ -49,4 +49,4 @@ def test_setup_py_can_read_version_module(): version_file = Path(buckaroo.__file__).parent / "_version.py" namespace = runpy.run_path(str(version_file)) - assert namespace["VERSION"] == "1.1.0" + assert namespace["VERSION"] == "1.2.0" From e593248fd7610e88a368a5dee19c10b6d953c770 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 26 Oct 2026 11:34:41 +0100 Subject: [PATCH 4/5] ci(BTI-1790): pin test runner to ubuntu-24.04 - Keep the Python 3.9 job running, since ubuntu-latest (26.04) has no 3.9 build --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3cc8cf3..889ff30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ concurrency: jobs: tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false From 3210051eb0d22708c2d96a4940ff11174ee5d870 Mon Sep 17 00:00:00 2001 From: Vildan Bina Date: Mon, 26 Oct 2026 12:26:19 +0100 Subject: [PATCH 5/5] docs(BTI-1790): reword BTI-1701 changelog entry as security update --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4277044..aef24ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file. ## [Released] ## [1.2.0] -- BTI-1701 Fix unpaid payments reported as successful and secrets exposed by curl and logs +- BTI-1701 Security update - BTI-1351 Remove GoSettle as payment method (deprecated) ## [1.1.0]