From aff846cd63892dc5a90de2ab279c35a4e3ff0c75 Mon Sep 17 00:00:00 2001 From: jar-stripe Date: Thu, 15 Oct 2026 16:17:14 -0700 Subject: [PATCH 1/3] Add API v2 Search support (#1939) * Add API v2 search result pagination Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Send v2 Search limit as a query parameter Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Preserve v2 Search page URLs Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Add v2 Search changefile Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Refine v2 Search changefile Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Restore v2 Search change title Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * Refine auto-pagination change wording Committed-By-Agent: goose Orbit-Session-Id: ac7fd08a-e385-4498-800e-21f51d3e4ad6 * fixed tests and ran just prepare --- ...2026-10-08_jar_v2-search-support.change.md | 9 ++ stripe/_object_classes.py | 4 + stripe/_stripe_service.py | 28 +++++- stripe/_util.py | 1 + stripe/v2/__init__.py | 3 + stripe/v2/_search_result_object.py | 80 +++++++++++++++++ tests/test_v2_search_result_object.py | 88 +++++++++++++++++++ 7 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 .hark/changes/2026-10-08_jar_v2-search-support.change.md create mode 100644 stripe/v2/_search_result_object.py create mode 100644 tests/test_v2_search_result_object.py diff --git a/.hark/changes/2026-10-08_jar_v2-search-support.change.md b/.hark/changes/2026-10-08_jar_v2-search-support.change.md new file mode 100644 index 000000000..a848ecd9c --- /dev/null +++ b/.hark/changes/2026-10-08_jar_v2-search-support.change.md @@ -0,0 +1,9 @@ +--- +title: Add API v2 Search support +pr_url: https://github.com/stripe/stripe-python/pull/1939 +semver_level: minor +jira_tickets_closed: +- DEVSDK-3316 +--- + +Adds `stripe.v2.SearchResultObject[T]` and support for auto pagination of search results. diff --git a/stripe/_object_classes.py b/stripe/_object_classes.py index 377c69906..3f7679509 100644 --- a/stripe/_object_classes.py +++ b/stripe/_object_classes.py @@ -344,6 +344,10 @@ } V2_OBJECT_CLASSES: Dict[str, Tuple[str, str]] = { + "v2.search_result": ( + "stripe.v2._search_result_object", + "SearchResultObject", + ), # V2 Object classes: The beginning of the section generated from our OpenAPI spec "v2.billing.meter_event": ("stripe.v2.billing._meter_event", "MeterEvent"), "v2.billing.meter_event_adjustment": ( diff --git a/stripe/_stripe_service.py b/stripe/_stripe_service.py index 174858968..1088f64b6 100644 --- a/stripe/_stripe_service.py +++ b/stripe/_stripe_service.py @@ -9,11 +9,25 @@ from stripe._request_options import RequestOptions from stripe._base_address import BaseAddress -from typing import Any, Dict, Mapping, Optional +from typing import Any, Dict, Mapping, Optional, Tuple +from urllib.parse import quote from stripe._encode import _coerce_v2_params, _SchemaNode +def _split_v2_search_params( + url: str, params: Optional[Mapping[str, Any]] +) -> Tuple[str, Optional[Mapping[str, Any]]]: + if params is None or "limit" not in params: + return url, params + body_params = dict(params) + limit = body_params.pop("limit") + if "limit=" not in url: + separator = "&" if "?" in url else "?" + url = "%s%slimit=%s" % (url, separator, quote(str(limit), safe="")) + return url, body_params + + class StripeService(object): _requestor: _APIRequestor @@ -32,6 +46,12 @@ def _request( ) -> StripeObject: if _param_encodings: params = _coerce_v2_params(params, _param_encodings) + if ( + method == "post" + and url.split("?", 1)[0].startswith("/v2/") + and url.split("?", 1)[0].endswith("/search") + ): + url, params = _split_v2_search_params(url, params) return self._requestor.request( method, url, @@ -53,6 +73,12 @@ async def _request_async( ) -> StripeObject: if _param_encodings: params = _coerce_v2_params(params, _param_encodings) + if ( + method == "post" + and url.split("?", 1)[0].startswith("/v2/") + and url.split("?", 1)[0].endswith("/search") + ): + url, params = _split_v2_search_params(url, params) return await self._requestor.request_async( method, url, diff --git a/stripe/_util.py b/stripe/_util.py index 8b378c63c..e034b209f 100644 --- a/stripe/_util.py +++ b/stripe/_util.py @@ -288,6 +288,7 @@ def _convert_to_stripe_object( and ( (getattr(obj, "object") == "list") or (getattr(obj, "object") == "search_result") + or (getattr(obj, "object") == "v2.search_result") ) ): obj._retrieve_params = params diff --git a/stripe/v2/__init__.py b/stripe/v2/__init__.py index 3eb03dfd8..68ab3737f 100644 --- a/stripe/v2/__init__.py +++ b/stripe/v2/__init__.py @@ -1,5 +1,8 @@ from typing_extensions import TYPE_CHECKING from stripe.v2._list_object import ListObject as ListObject +from stripe.v2._search_result_object import ( + SearchResultObject as SearchResultObject, +) from stripe.v2._amount import Amount as Amount, AmountParam as AmountParam diff --git a/stripe/v2/_search_result_object.py b/stripe/v2/_search_result_object.py new file mode 100644 index 000000000..972aa382a --- /dev/null +++ b/stripe/v2/_search_result_object.py @@ -0,0 +1,80 @@ +from copy import deepcopy +from typing import ( + AsyncIterator, + Generic, + Iterator, + List, + Mapping, + Any, + Optional, + TypeVar, +) + +from stripe._any_iterator import AnyIterator +from stripe._stripe_object import StripeObject + + +T = TypeVar("T", bound=StripeObject) + + +class SearchResultObject(StripeObject, Generic[T]): + """A page of API v2 search results with POST-based auto-pagination.""" + + OBJECT_NAME = "v2.search_result" + data: List[T] + next_page_url: Optional[str] + previous_page_url: Optional[str] + total_count: int + + def __iter__(self) -> Iterator[T]: + return getattr(self, "data", []).__iter__() + + def __len__(self) -> int: + return getattr(self, "data", []).__len__() + + def auto_paging_iter(self) -> AnyIterator[T]: + return AnyIterator( + self._auto_paging_iter(), self._auto_paging_iter_async() + ) + + def _original_params(self) -> Mapping[str, Any]: + return deepcopy(self._retrieve_params) + + def _body_params(self, params: Mapping[str, Any]) -> Mapping[str, Any]: + body_params = dict(params) + body_params.pop("limit", None) + return body_params + + def _auto_paging_iter(self) -> Iterator[T]: + page: SearchResultObject[T] = self + params = self._original_params() + while True: + for item in page.data: + yield item + if page.next_page_url is None: + break + result = self._request( + "post", + page.next_page_url, + params=self._body_params(params), + base_address="api", + ) + assert isinstance(result, SearchResultObject) + page = result + + async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + page: SearchResultObject[T] = self + params = self._original_params() + while True: + for item in page.data: + yield item + if page.next_page_url is None: + break + result = await self._request_async( + "post", + page.next_page_url, + params=self._body_params(params), + base_address="api", + ) + assert isinstance(result, SearchResultObject) + page = result diff --git a/tests/test_v2_search_result_object.py b/tests/test_v2_search_result_object.py new file mode 100644 index 000000000..002b606d8 --- /dev/null +++ b/tests/test_v2_search_result_object.py @@ -0,0 +1,88 @@ +import pytest + +from stripe.v2._search_result_object import SearchResultObject + + +class TestSearchResult(SearchResultObject): + requests = [] + pages = [] + + def _request(self, method, url, params=None, **kwargs): + self.requests.append((method, url, params, kwargs)) + return self.pages.pop(0) + + async def _request_async(self, method, url, params=None, **kwargs): + return self._request(method, url, params=params, **kwargs) + + +def make_result(data, next_page_url): + result = TestSearchResult._construct_from( + values={ + "object": "v2.search_result", + "data": data, + "next_page_url": next_page_url, + "previous_page_url": None, + "total_count": 2, + }, + last_response=None, + requestor=None, + api_mode="V2", + ) + return result + + +def test_auto_paging_replays_original_post_body(): + params = { + "query": 'status:"active"', + "sort": ["name", "-created"], + "limit": 2, + "future_field": {"enabled": True}, + } + first = make_result(["one"], "/v2/widgets/search?page=2&limit=2") + first._retrieve_params = params + TestSearchResult.requests = [] + TestSearchResult.pages = [ + make_result([], "/v2/widgets/search?page=3&limit=2"), + make_result(["two"], None), + ] + + assert list(first.auto_paging_iter()) == ["one", "two"] + assert [ + (method, url, body) + for method, url, body, _ in TestSearchResult.requests + ] == [ + ( + "post", + "/v2/widgets/search?page=2&limit=2", + { + "query": 'status:"active"', + "sort": ["name", "-created"], + "future_field": {"enabled": True}, + }, + ), + ( + "post", + "/v2/widgets/search?page=3&limit=2", + { + "query": 'status:"active"', + "sort": ["name", "-created"], + "future_field": {"enabled": True}, + }, + ), + ] + + +@pytest.mark.anyio +async def test_async_auto_paging_replays_original_post_body(): + first = make_result(["one"], "/v2/widgets/search?page=2") + first._retrieve_params = {"query": "widgets"} + TestSearchResult.requests = [] + TestSearchResult.pages = [make_result(["two"], None)] + + results = [item async for item in first.auto_paging_iter()] + assert results == ["one", "two"] + assert TestSearchResult.requests[0][:3] == ( + "post", + "/v2/widgets/search?page=2", + {"query": "widgets"}, + ) From 609ba6a90e1f550d0eaa5fe8da51f62d1409bf79 Mon Sep 17 00:00:00 2001 From: zacchua-stripe Date: Mon, 19 Oct 2026 12:27:01 -0700 Subject: [PATCH 2/3] Make Stripe errors serializable (#1944) * Make Stripe errors serializable Committed-By-Agent: codex Co-authored-by: codex * Add serialization fix changefile Committed-By-Agent: codex Co-authored-by: codex --------- Co-authored-by: codex --- ..._make-stripe-errors-serializable.change.md | 11 +++ stripe/_error.py | 30 +++++- stripe/oauth_error.py | 8 ++ tests/test_error.py | 96 ++++++++++++++++++- tests/test_oauth_error.py | 37 +++++++ 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 .hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md diff --git a/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md b/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md new file mode 100644 index 000000000..55da12fc4 --- /dev/null +++ b/.hark/changes/2026-10-14_zacchua_make-stripe-errors-serializable.change.md @@ -0,0 +1,11 @@ +--- +title: Make Stripe errors serializable +pr_url: https://github.com/stripe/stripe-python/pull/1944 +semver_level: patch +jira_tickets_closed: +- RUN_DEVSDK-3443 +github_issues_resolved: +- https://github.com/stripe/stripe-python/issues/1941 +--- + +Stripe exceptions can now be serialized and deserialized with Python `pickle` and Celery JSON result serialization without losing their type or attributes. diff --git a/stripe/_error.py b/stripe/_error.py index 76e26bb4a..bcb8b8e1d 100644 --- a/stripe/_error.py +++ b/stripe/_error.py @@ -22,8 +22,6 @@ def __init__( headers: Optional[Dict[str, str]] = None, code: Optional[str] = None, ): - super(StripeError, self).__init__(message) - body: Optional[str] = None if http_body: # http_body can sometimes be a memoryview which must be cast @@ -46,6 +44,14 @@ def __init__( self.json_body = json_body self.headers = headers or {} self.code = code + super(StripeError, self).__init__( + message, + self.http_body, + self.http_status, + self.json_body, + self.headers, + self.code, + ) self.request_id = self.headers.get("request-id", None) self.error = self._construct_error_object() @@ -113,6 +119,7 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.should_retry = should_retry + self.args = self.args + (self.should_retry,) class StripeErrorWithParamCode(StripeError): @@ -146,6 +153,15 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.param = param + self.args = ( + self._message, + self.param, + self.code, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) class IdempotencyError(StripeError): @@ -167,6 +183,15 @@ def __init__( message, http_body, http_status, json_body, headers, code ) self.param = param + self.args = ( + self._message, + self.param, + self.code, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) class AuthenticationError(StripeError): @@ -185,6 +210,7 @@ class SignatureVerificationError(StripeError): def __init__(self, message, sig_header, http_body=None): super(SignatureVerificationError, self).__init__(message, http_body) self.sig_header = sig_header + self.args = (self._message, self.sig_header, self.http_body) # classDefinitions: The beginning of the section generated from our OpenAPI spec diff --git a/stripe/oauth_error.py b/stripe/oauth_error.py index 14f75c2af..5fd0fb1e8 100644 --- a/stripe/oauth_error.py +++ b/stripe/oauth_error.py @@ -15,6 +15,14 @@ def __init__( super(OAuthError, self).__init__( description, http_body, http_status, json_body, headers, code ) + self.args = ( + self.code, + self._message, + self.http_body, + self.http_status, + self.json_body, + self.headers, + ) def _construct_error_object(self): if self.json_body is None: diff --git a/tests/test_error.py b/tests/test_error.py index 73de812ac..f7d386eb9 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -2,8 +2,37 @@ import json - -from stripe._error import StripeError, CardError, APIConnectionError +import pickle + +import pytest + +from stripe._error import ( + APIConnectionError, + APIError, + AuthenticationError, + CardError, + IdempotencyError, + InvalidRequestError, + PermissionError, + RateLimitError, + SignatureVerificationError, + StripeError, + StripeErrorWithParamCode, + TemporarySessionExpiredError, +) + + +def assert_error_round_trips(err): + json_args = json.loads(json.dumps(err.args)) + round_tripped_errors = ( + pickle.loads(pickle.dumps(err)), + type(err)(*json_args), + ) + + for round_tripped in round_tripped_errors: + assert type(round_tripped) is type(err) + assert round_tripped.args == err.args + assert vars(round_tripped) == vars(err) class TestStripeError(object): @@ -114,3 +143,66 @@ def test_default_no_retry(self): err = APIConnectionError("msg", should_retry=True) assert err.should_retry + + +@pytest.mark.parametrize( + "error_class", + [ + StripeError, + APIError, + AuthenticationError, + IdempotencyError, + PermissionError, + RateLimitError, + StripeErrorWithParamCode, + TemporarySessionExpiredError, + ], +) +def test_stripe_error_round_trips(error_class): + err = error_class( + message="message", + http_body=b"body", + http_status=400, + json_body={"error": {"code": "error_code"}}, + headers={"request-id": "req_123"}, + code="error_code", + ) + + assert_error_round_trips(err) + + +@pytest.mark.parametrize( + "err", + [ + APIConnectionError( + "message", + b"body", + 400, + {"error": {"code": "error_code"}}, + {"request-id": "req_123"}, + "error_code", + True, + ), + CardError( + "message", + "param", + "error_code", + b"body", + 402, + {"error": {"code": "error_code"}}, + {"request-id": "req_123"}, + ), + InvalidRequestError( + message="message", + param="param", + code="error_code", + http_body=b"body", + http_status=400, + json_body={"error": {"code": "error_code"}}, + headers={"request-id": "req_123"}, + ), + SignatureVerificationError("message", "signature", b"body"), + ], +) +def test_stripe_error_with_custom_constructor_round_trips(err): + assert_error_round_trips(err) diff --git a/tests/test_oauth_error.py b/tests/test_oauth_error.py index 5eee7b66e..a8bf1e68d 100644 --- a/tests/test_oauth_error.py +++ b/tests/test_oauth_error.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- +import json +import pickle + +import pytest + from stripe import oauth_error @@ -11,3 +16,35 @@ def test_error_object(self): ) assert err.error is not None assert err.error.error == "some_oauth_error" + + +@pytest.mark.parametrize( + "error_class", + [ + oauth_error.OAuthError, + oauth_error.InvalidClientError, + oauth_error.InvalidGrantError, + oauth_error.InvalidRequestError, + oauth_error.InvalidScopeError, + oauth_error.UnsupportedGrantTypeError, + oauth_error.UnsupportedResponseTypeError, + ], +) +def test_oauth_error_round_trips(error_class): + err = error_class( + code="oauth_code", + description="description", + http_body=b"body", + http_status=400, + json_body={"error": "oauth_code"}, + headers={"request-id": "req_123"}, + ) + json_args = json.loads(json.dumps(err.args)) + + for round_tripped in ( + pickle.loads(pickle.dumps(err)), + error_class(*json_args), + ): + assert type(round_tripped) is error_class + assert round_tripped.args == err.args + assert vars(round_tripped) == vars(err) From 89e8528a61d838600097ffe5c9615c9b7d35778a Mon Sep 17 00:00:00 2001 From: zacchua-stripe Date: Mon, 19 Oct 2026 17:03:49 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Reject=20blank=20webhook=20secrets=20?= =?UTF-8?q?=F0=9F=AA=BF=E2=9C=A8=20(#1940)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reject blank webhook secrets Committed-By-Agent: goose Orbit-Session-Id: a8a3ff0b-2818-4677-9877-6974eda81782 * Add blank webhook secret changefile Committed-By-Agent: codex Co-authored-by: codex * Clarify missing webhook secret tests Committed-By-Agent: codex Co-authored-by: codex --------- Co-authored-by: codex --- ...hua_reject-blank-webhook-secrets.change.md | 9 +++ stripe/_webhook.py | 2 +- tests/test_v2_event.py | 28 +++++++- tests/test_webhook.py | 65 ++++++++++++++++++- 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 .hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md diff --git a/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md new file mode 100644 index 000000000..2f03b4b74 --- /dev/null +++ b/.hark/changes/2026-10-02_zacchua_reject-blank-webhook-secrets.change.md @@ -0,0 +1,9 @@ +--- +title: Reject ASCII-whitespace-only webhook secrets +pr_url: https://github.com/stripe/stripe-python/pull/1940 +semver_level: patch +jira_tickets_closed: +- RUN_DEVSDK-3378 +--- + +- Reject webhook signing secrets made entirely of ASCII whitespace instead of using them as HMAC keys. diff --git a/stripe/_webhook.py b/stripe/_webhook.py index 9fc269e69..a9a041454 100644 --- a/stripe/_webhook.py +++ b/stripe/_webhook.py @@ -150,7 +150,7 @@ def verify_header( header, payload, ) - if not secret: + if secret is None or secret.strip(" \t\r\n\f\v") == "": raise SignatureVerificationError( "No webhook secret value was provided. It should start with `whsec_`", header, diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py index 54a1f2e2d..11909fae9 100644 --- a/tests/test_v2_event.py +++ b/tests/test_v2_event.py @@ -21,7 +21,10 @@ ) from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS from stripe._webhook import WebhookPayload, WebhookSignature -from tests.test_webhook import DUMMY_WEBHOOK_SECRET +from tests.test_webhook import ( + ASCII_WHITESPACE_WEBHOOK_SECRETS, + DUMMY_WEBHOOK_SECRET, +) EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS] @@ -252,7 +255,28 @@ def test_rejects_missing_secret( stripe_client.parse_event_notification( v2_payload_no_data, WebhookSignature.generate_signature_header( - v2_payload_no_data, DUMMY_WEBHOOK_SECRET + v2_payload_no_data, + DUMMY_WEBHOOK_SECRET, + ), + secret, + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_rejects_ascii_whitespace_secret( + self, + stripe_client: StripeClient, + v2_payload_no_data: str, + secret: str, + ): + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe_client.parse_event_notification( + v2_payload_no_data, + WebhookSignature.generate_signature_header( + v2_payload_no_data, + secret, ), secret, ) diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 08c9e20b7..2a884ef0a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -23,6 +23,16 @@ DUMMY_WEBHOOK_SECRET = "whsec_test_secret" +ASCII_WHITESPACE_WEBHOOK_SECRETS = [ + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + pytest.param("\r", id="carriage-return"), + pytest.param("\n", id="line-feed"), + pytest.param("\f", id="form-feed"), + pytest.param("\v", id="vertical-tab"), + pytest.param(" \t\r\n\f\v", id="mixed"), +] + def generate_header( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=None @@ -101,12 +111,24 @@ def test_construct_event_from_bytes(self): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): + header = generate_header() with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe.Webhook.construct_event( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret(self, secret): + header = generate_header(secret=secret) + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.Webhook.construct_event( + DUMMY_WEBHOOK_PAYLOAD, header, secret ) def test_raise_on_v2_payload(self): @@ -131,12 +153,24 @@ def test_raise_on_missing_header(self, header): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, secret): + header = generate_header() + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret(self, secret): + header = generate_header(secret=secret) with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe.WebhookSignature.verify_header( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret ) @pytest.mark.parametrize( @@ -199,6 +233,17 @@ def test_valid_header_and_signature(self): DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET, tolerance=10 ) + @pytest.mark.parametrize( + "secret", + [" \twhsec_test_secret\r\n", "\u00a0"], + ids=["surrounding-whitespace", "non-ascii-whitespace"], + ) + def test_preserves_nonblank_secret(self, secret): + header = generate_header(secret=secret) + assert stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + def test_header_contains_valid_signature(self): header = generate_header() + ",v1=bad_signature" assert stripe.WebhookSignature.verify_header( @@ -276,12 +321,26 @@ def test_raise_on_v2_payload(self, stripe_mock_stripe_client): @pytest.mark.parametrize("secret", [None, ""]) def test_raise_on_missing_secret(self, stripe_mock_stripe_client, secret): + header = generate_header() + with pytest.raises( + SignatureVerificationError, + match="No webhook secret value was provided", + ): + stripe_mock_stripe_client.construct_event( + DUMMY_WEBHOOK_PAYLOAD, header, secret + ) + + @pytest.mark.parametrize("secret", ASCII_WHITESPACE_WEBHOOK_SECRETS) + def test_raise_on_ascii_whitespace_secret( + self, stripe_mock_stripe_client, secret + ): + header = generate_header(secret=secret) with pytest.raises( SignatureVerificationError, match="No webhook secret value was provided", ): stripe_mock_stripe_client.construct_event( - DUMMY_WEBHOOK_PAYLOAD, generate_header(), secret + DUMMY_WEBHOOK_PAYLOAD, header, secret ) def test_construct_event_inherits_requestor(self, http_client_mock):