diff --git a/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md new file mode 100644 index 000000000..7e21096aa --- /dev/null +++ b/.hark/changes/2026-10-30_zacchua_omit-pickled-api-keys.change.md @@ -0,0 +1,5 @@ +--- +title: Omit API keys from pickled StripeObjects +semver_level: major +--- +StripeObject pickles no longer preserve API keys. Applications making object-based requests after cache or queue round trips must supply credentials, either through global configuration or per call, e.g. `customer.refresh(api_key=api_key)`. `refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on list and search result objects now accept an `api_key` keyword argument for this. See the [v17 migration guide](https://github.com/stripe/stripe-python/blob/master/.hark/migration-guides/v17.md#api-keys-are-no-longer-preserved-in-pickled-stripeobjects). diff --git a/.hark/migration-guides/v17.md b/.hark/migration-guides/v17.md new file mode 100644 index 000000000..8184bb29c --- /dev/null +++ b/.hark/migration-guides/v17.md @@ -0,0 +1,27 @@ +# Migration guide for v17 +## API keys are no longer preserved in pickled StripeObjects +This change affects integrations that persist StripeObjects in caches or queues and then make requests through the restored objects. + +New pickles omit API keys from StripeObjects, including nested objects. Restored objects keep their connected-account routing and API version. Before making object-based requests, pass a key from secure configuration in the method call: +```python +# Before +customer = pickle.loads(trusted_cached_bytes) +customer.refresh() +# After +customer = pickle.loads(trusted_cached_bytes) +customer.refresh(api_key=api_key) +customer.save(api_key=api_key) + +customers = pickle.loads(trusted_cached_list_bytes) +for customer in customers.auto_paging_iter(api_key=api_key): + ... +``` +`refresh()`, `refresh_async()`, `save()`, and `auto_paging_iter()` on `ListObject`, `SearchResultObject`, and their V2 equivalents now accept an `api_key` keyword argument for this. Other instance methods, such as `delete()` and resource actions like `cancel()`, already accept request options like `api_key`. Passing a key does not change `stripe.api_key`. + +### Global fallback +Plain `pickle.loads()` retains the existing fallback to global API-key configuration. If that is the intended credential, configuring it before making requests is an alternative to passing a key per call: +```python +stripe.api_key = api_key +customer = pickle.loads(trusted_cached_bytes) +customer.refresh() +``` diff --git a/stripe/_api_resource.py b/stripe/_api_resource.py index f67eb5f16..d301d6f5f 100644 --- a/stripe/_api_resource.py +++ b/stripe/_api_resource.py @@ -1,8 +1,11 @@ -from typing_extensions import Literal, Self, deprecated +from typing_extensions import Literal, Self, Unpack, deprecated from stripe._error import InvalidRequestError from stripe._stripe_object import StripeObject -from stripe._request_options import extract_options_from_dict +from stripe._request_options import ( + RequestOptions, + extract_options_from_dict, +) from stripe._api_mode import ApiMode from stripe._base_address import BaseAddress from stripe._api_requestor import _APIRequestor @@ -33,12 +36,18 @@ def retrieve(cls, id, **params) -> T: instance.refresh() return cast(T, instance) - def refresh(self) -> Self: - return self._request_and_refresh("get", self.instance_url()) + def refresh(self, **params: Unpack[RequestOptions]) -> Self: + return self._request_and_refresh( + "get", + self.instance_url(), + params={**self._retrieve_params, **params}, + ) - async def refresh_async(self) -> Self: + async def refresh_async(self, **params: Unpack[RequestOptions]) -> Self: return await self._request_and_refresh_async( - "get", self.instance_url() + "get", + self.instance_url(), + params={**self._retrieve_params, **params}, ) @classmethod diff --git a/stripe/_list_object.py b/stripe/_list_object.py index c88e7e078..6775cdd6c 100644 --- a/stripe/_list_object.py +++ b/stripe/_list_object.py @@ -116,13 +116,15 @@ def __len__(self) -> int: def __reversed__(self) -> Iterator[T]: return getattr(self, "data", []).__reversed__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), - self._auto_paging_iter_async(), + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self while True: @@ -132,16 +134,18 @@ def _auto_paging_iter(self) -> Iterator[T]: ): for item in reversed(page): yield item - page = page.previous_page() + page = page.previous_page(**params) else: for item in page: yield item - page = page.next_page() + page = page.next_page(**params) if page.is_empty: break - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self while True: @@ -151,11 +155,11 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: ): for item in reversed(page): yield item - page = await page.previous_page_async() + page = await page.previous_page_async(**params) else: for item in page: yield item - page = await page.next_page_async() + page = await page.next_page_async(**params) if page.is_empty: break diff --git a/stripe/_search_result_object.py b/stripe/_search_result_object.py index 75dc941d2..0ec51935e 100644 --- a/stripe/_search_result_object.py +++ b/stripe/_search_result_object.py @@ -86,29 +86,34 @@ def __iter__(self) -> Iterator[T]: def __len__(self) -> int: return getattr(self, "data", []).__len__() - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self while True: for item in page: yield item - page = page.next_search_result_page() + page = page.next_search_result_page(**params) if page.is_empty: break - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), self._auto_paging_iter_async() + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self while True: for item in page: yield item - page = await page.next_search_result_page_async() + page = await page.next_search_result_page_async(**params) if page.is_empty: break diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index 8fe6d87d7..7cca7620c 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -298,7 +298,7 @@ def __reduce__(self) -> Tuple[Any, ...]: type(self), # callable ( # args getattr(self, "id", None), - self.api_key, + None, # Never persist SDK authentication credentials. self.stripe_version, self.stripe_account, ), diff --git a/stripe/_updateable_api_resource.py b/stripe/_updateable_api_resource.py index 9f69deae5..dd05ac4cd 100644 --- a/stripe/_updateable_api_resource.py +++ b/stripe/_updateable_api_resource.py @@ -17,10 +17,11 @@ def modify(cls, sid, **params) -> T: @deprecated( "The `save` method is deprecated and will be removed in a future major version of the library. Use the class method `modify` on the resource instead." ) - def save(self, idempotency_key=None): + def save(self, idempotency_key=None, *, api_key=None): updated_params = self.serialize(None) if updated_params: updated_params["idempotency_key"] = idempotency_key + updated_params["api_key"] = api_key self._request_and_refresh( "post", self.instance_url(), diff --git a/stripe/v2/_list_object.py b/stripe/v2/_list_object.py index 08fcbd016..602a8fa7a 100644 --- a/stripe/v2/_list_object.py +++ b/stripe/v2/_list_object.py @@ -1,4 +1,7 @@ +from typing_extensions import Unpack + from stripe._any_iterator import AnyIterator +from stripe._request_options import RequestOptions from stripe._stripe_object import StripeObject from typing import AsyncIterator, Iterator, List, Optional, TypeVar, Generic @@ -41,13 +44,15 @@ def __len__(self): def __reversed__(self): return getattr(self, "data", []).__reversed__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), - self._auto_paging_iter_async(), + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, params: RequestOptions) -> Iterator[T]: page = self.data next_page_url = self.next_page_url while True: @@ -59,13 +64,16 @@ def _auto_paging_iter(self) -> Iterator[T]: result = self._request( "get", next_page_url, + params={**self._retrieve_params, **params}, base_address="api", ) assert isinstance(result, ListObject) page = result.data next_page_url = result.next_page_url - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, params: RequestOptions + ) -> AsyncIterator[T]: page = self.data next_page_url = self.next_page_url while True: @@ -77,6 +85,7 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: result = await self._request_async( "get", next_page_url, + params={**self._retrieve_params, **params}, base_address="api", ) assert isinstance(result, ListObject) diff --git a/stripe/v2/_search_result_object.py b/stripe/v2/_search_result_object.py index 972aa382a..5dd20833b 100644 --- a/stripe/v2/_search_result_object.py +++ b/stripe/v2/_search_result_object.py @@ -10,7 +10,10 @@ TypeVar, ) +from typing_extensions import Unpack + from stripe._any_iterator import AnyIterator +from stripe._request_options import RequestOptions from stripe._stripe_object import StripeObject @@ -32,9 +35,12 @@ def __iter__(self) -> Iterator[T]: def __len__(self) -> int: return getattr(self, "data", []).__len__() - def auto_paging_iter(self) -> AnyIterator[T]: + def auto_paging_iter( + self, **params: Unpack[RequestOptions] + ) -> AnyIterator[T]: return AnyIterator( - self._auto_paging_iter(), self._auto_paging_iter_async() + self._auto_paging_iter(params), + self._auto_paging_iter_async(params), ) def _original_params(self) -> Mapping[str, Any]: @@ -45,7 +51,7 @@ def _body_params(self, params: Mapping[str, Any]) -> Mapping[str, Any]: body_params.pop("limit", None) return body_params - def _auto_paging_iter(self) -> Iterator[T]: + def _auto_paging_iter(self, options: RequestOptions) -> Iterator[T]: page: SearchResultObject[T] = self params = self._original_params() while True: @@ -56,13 +62,15 @@ def _auto_paging_iter(self) -> Iterator[T]: result = self._request( "post", page.next_page_url, - params=self._body_params(params), + params={**self._body_params(params), **options}, base_address="api", ) assert isinstance(result, SearchResultObject) page = result - async def _auto_paging_iter_async(self) -> AsyncIterator[T]: + async def _auto_paging_iter_async( + self, options: RequestOptions + ) -> AsyncIterator[T]: page: SearchResultObject[T] = self params = self._original_params() while True: @@ -73,7 +81,7 @@ async def _auto_paging_iter_async(self) -> AsyncIterator[T]: result = await self._request_async( "post", page.next_page_url, - params=self._body_params(params), + params={**self._body_params(params), **options}, base_address="api", ) assert isinstance(result, SearchResultObject) diff --git a/tests/api_resources/test_list_object.py b/tests/api_resources/test_list_object.py index 02568c4b7..243c06396 100644 --- a/tests/api_resources/test_list_object.py +++ b/tests/api_resources/test_list_object.py @@ -1,4 +1,5 @@ import json +import pickle import pytest @@ -297,6 +298,76 @@ def test_iter_two_pages(self, http_client_mock): assert seen == ["pm_123", "pm_124", "pm_125", "pm_126"] + @pytest.fixture + def restored_list(self, monkeypatch): + lo = stripe.ListObject.construct_from( + self.pageable_model_response(["pm_123"], True), + "producer-key-sentinel", + stripe_account="example-account", + ) + monkeypatch.setattr(stripe, "api_key", None) + return pickle.loads(pickle.dumps(lo)) + + def test_iter_uses_per_call_api_key_on_every_page( + self, http_client_mock, restored_list + ): + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_124"], True)), + ) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_124", + rbody=json.dumps(self.pageable_model_response(["pm_125"], False)), + ) + + seen = [ + item["id"] + for item in restored_list.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124", "pm_125"] + for starting_after in ("pm_123", "pm_124"): + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string=f"starting_after={starting_after}", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_iter_reverse_uses_per_call_api_key( + self, http_client_mock, restored_list + ): + restored_list._retrieve_params = {"ending_before": "pm_124"} + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="ending_before=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_122"], False)), + ) + + seen = [ + item["id"] + for item in restored_list.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_122"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="ending_before=pm_123", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + def test_iter_reverse(self, http_client_mock): lo = stripe.ListObject.construct_from( self.pageable_model_response(["pm_125", "pm_126"], True), "mykey" @@ -655,6 +726,38 @@ async def test_iter_one_page(self, http_client_mock): assert seen == ["pm_123", "pm_124"] + @pytest.mark.anyio + async def test_iter_uses_per_call_api_key( + self, http_client_mock, monkeypatch + ): + lo = stripe.ListObject.construct_from( + self.pageable_model_response(["pm_123"], True), + "producer-key-sentinel", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(lo)) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + rbody=json.dumps(self.pageable_model_response(["pm_124"], False)), + ) + + seen = [ + item["id"] + async for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="starting_after=pm_123", + api_key="worker-key-sentinel", + ) + @pytest.mark.anyio async def test_iter_two_pages(self, http_client_mock): lo = stripe.ListObject.construct_from( diff --git a/tests/api_resources/test_list_object_v2.py b/tests/api_resources/test_list_object_v2.py index 84759e873..34da50c1d 100644 --- a/tests/api_resources/test_list_object_v2.py +++ b/tests/api_resources/test_list_object_v2.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division, print_function import json +import pickle import pytest @@ -147,6 +148,37 @@ def test_iter_forwards_api_key(self, http_client_mock: HTTPClientMock): api_key="sk_test_iter_forwards_options", ) + def test_iter_uses_per_call_api_key(self, http_client_mock, monkeypatch): + lo = ListObject.construct_from( + self.pageable_model_response( + ["pm_123"], "/v2/pageablemodels?page=page_2" + ), + "producer-key-sentinel", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(lo)) + http_client_mock.stub_request( + "get", + path="/v2/pageablemodels", + query_string="page=page_2", + rbody=json.dumps(self.pageable_model_response(["pm_124"], None)), + ) + + seen = [ + item["id"] + for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v2/pageablemodels", + query_string="page=page_2", + api_key="worker-key-sentinel", + ) + class TestAutoPagingAsync: @staticmethod diff --git a/tests/api_resources/test_search_result_object.py b/tests/api_resources/test_search_result_object.py index 9db977921..af31b3bd6 100644 --- a/tests/api_resources/test_search_result_object.py +++ b/tests/api_resources/test_search_result_object.py @@ -1,4 +1,5 @@ import json +import pickle import pytest @@ -256,6 +257,39 @@ def test_iter_two_pages(self, http_client_mock): assert seen == ["pm_123", "pm_124", "pm_125", "pm_126"] + def test_iter_uses_per_call_api_key(self, http_client_mock, monkeypatch): + sro = stripe.SearchResultObject.construct_from( + self.pageable_model_response(["pm_123"], True, "token"), + "producer-key-sentinel", + stripe_account="example-account", + ) + monkeypatch.setattr(stripe, "api_key", None) + restored = pickle.loads(pickle.dumps(sro)) + http_client_mock.stub_request( + "get", + path="/v1/pageablemodels", + query_string="page=token", + rbody=json.dumps( + self.pageable_model_response(["pm_124"], False, None) + ), + ) + + seen = [ + item["id"] + for item in restored.auto_paging_iter( + api_key="worker-key-sentinel" + ) + ] + + assert seen == ["pm_123", "pm_124"] + http_client_mock.assert_requested( + "get", + path="/v1/pageablemodels", + query_string="page=token", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + class TestAutoPagingAsync: @staticmethod diff --git a/tests/test_stripe_object.py b/tests/test_stripe_object.py index aecd64938..edf902359 100644 --- a/tests/test_stripe_object.py +++ b/tests/test_stripe_object.py @@ -274,30 +274,237 @@ def test_repr(self): assert '"date": 1511136000' in res assert '"dec": "1.23"' in res - def test_pickling(self): - obj = StripeObject("foo", "bar", myparam=5) - - obj["object"] = "boo" - obj.refresh_from( + @pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) + @pytest.mark.parametrize( + "destination_key", [None, "destination-key-sentinel"] + ) + def test_pickling(self, protocol, destination_key, monkeypatch): + obj = stripe.Customer.construct_from( { - "fala": "lalala", - # ensures that empty strings are correctly unpickled in Py3 - "emptystring": "", + "id": "resource", + "object": "customer", + "description": "example", + "metadata": {"emptystring": ""}, }, - api_key="bar", - partial=True, + "producer-key-sentinel", + stripe_version="2017-08-15", + stripe_account="example-account", + ) + obj.metadata.api_key = "nested-key-sentinel" + nested = stripe.Customer.construct_from( + {"id": "nested-resource", "object": "customer"}, + "nested-key-sentinel", + stripe_version="2018-02-28", + stripe_account="nested-example-account", + ) + obj.containers = [{"nested": nested}] + original_requestor = obj._requestor + payload = obj.to_dict() + + for value in (obj.metadata, obj): + assert value.__reduce__()[1][1] is None + pickled = pickle.dumps(value, protocol=protocol) + assert b"producer-key-sentinel" not in pickled + assert b"nested-key-sentinel" not in pickled + + monkeypatch.setattr(stripe, "api_key", destination_key) + restored = pickle.loads(pickled) + + assert type(restored) is stripe.Customer + assert restored.to_dict() == payload + for value in (restored, restored.metadata): + assert value.api_key == destination_key + assert value.stripe_version == "2017-08-15" + assert value.stripe_account == "example-account" + assert obj.api_key == "producer-key-sentinel" + assert obj.metadata.api_key == "nested-key-sentinel" + assert obj._requestor is original_requestor + assert obj.to_dict() == payload + + restored_nested = restored.containers[0]["nested"] + assert restored_nested.api_key == destination_key + assert restored_nested.stripe_account == "nested-example-account" + assert restored_nested.stripe_version == "2018-02-28" + + pickled = pickle.dumps(restored, protocol=protocol) + for key in ( + b"producer-key-sentinel", + b"nested-key-sentinel", + b"destination-key-sentinel", + ): + assert key not in pickled + + def test_pickling_verified_webhook(self, monkeypatch): + payload = json.dumps( + { + "object": "event", + "data": { + "object": {"object": "customer", "metadata": {"empty": ""}} + }, + } + ) + monkeypatch.setattr(stripe, "api_key", "producer-key-sentinel") + signature = stripe.WebhookSignature.generate_signature_header( + payload, "webhook-test-secret" + ) + event = stripe.Webhook.construct_event( + payload, signature, "webhook-test-secret" + ) + pickled = pickle.dumps(event) + assert b"producer-key-sentinel" not in pickled + monkeypatch.setattr(stripe, "api_key", "destination-key-sentinel") + restored = pickle.loads(pickled) + + assert type(restored) is stripe.Event + assert type(restored.data.object) is stripe.Customer + assert restored.to_dict() == event.to_dict() + for value in ( + restored, + restored.data, + restored.data.object, + restored.data.object.metadata, + ): + assert value.api_key == "destination-key-sentinel" + assert event.api_key == "producer-key-sentinel" + + @pytest.fixture + def restored_customer(self, monkeypatch): + obj = stripe.Customer.construct_from( + {"id": "resource", "object": "customer"}, + "producer-key-sentinel", + stripe_account="example-account", + ) + monkeypatch.setattr(stripe, "api_key", None) + return pickle.loads(pickle.dumps(obj)) + + def test_restored_refresh_without_key_raises( + self, http_client_mock, restored_customer + ): + with pytest.raises(stripe.AuthenticationError): + restored_customer.refresh() + + http_client_mock.assert_no_request() + + def test_restored_refresh_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "refreshed"}', ) - assert obj.fala == "lalala" + assert ( + restored_customer.refresh(api_key="worker-key-sentinel") + is restored_customer + ) - pickled = pickle.dumps(obj) - newobj = pickle.loads(pickled) + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + assert restored_customer.description == "refreshed" + assert stripe.api_key is None + assert b"worker-key-sentinel" not in pickle.dumps(restored_customer) - assert newobj.id == "foo" - assert newobj.api_key == "bar" - assert newobj["object"] == "boo" - assert newobj.fala == "lalala" - assert newobj.emptystring == "" + @pytest.mark.anyio + async def test_restored_refresh_async_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer"}', + ) + + await restored_customer.refresh_async(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_refresh_preserves_retrieve_params(self, http_client_mock): + obj = stripe.Customer( + "resource", "producer-key-sentinel", expand=["sources"] + ) + http_client_mock.stub_request( + "get", + path="/v1/customers/resource", + query_string="expand[0]=sources", + rbody='{"id": "resource", "object": "customer"}', + ) + + obj.refresh(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "get", + path="/v1/customers/resource", + query_string="expand[0]=sources", + api_key="worker-key-sentinel", + ) + + def test_restored_save_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + restored_customer.description = "changed" + http_client_mock.stub_request( + "post", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "changed"}', + ) + + restored_customer.save(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "post", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) + + def test_save_keeps_positional_idempotency_key( + self, http_client_mock, restored_customer + ): + restored_customer.description = "changed" + http_client_mock.stub_request( + "post", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "description": "changed"}', + ) + + restored_customer.save( + "idempotency-sentinel", api_key="worker-key-sentinel" + ) + + http_client_mock.assert_requested( + "post", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + idempotency_key="idempotency-sentinel", + ) + + def test_restored_delete_uses_per_call_api_key( + self, http_client_mock, restored_customer + ): + http_client_mock.stub_request( + "delete", + path="/v1/customers/resource", + rbody='{"id": "resource", "object": "customer", "deleted": true}', + ) + + restored_customer.delete(api_key="worker-key-sentinel") + + http_client_mock.assert_requested( + "delete", + path="/v1/customers/resource", + api_key="worker-key-sentinel", + stripe_account="example-account", + ) def test_deletion(self): obj = StripeObject("id", "key") diff --git a/tests/test_v2_search_result_object.py b/tests/test_v2_search_result_object.py index 002b606d8..7fc413267 100644 --- a/tests/test_v2_search_result_object.py +++ b/tests/test_v2_search_result_object.py @@ -86,3 +86,23 @@ async def test_async_auto_paging_replays_original_post_body(): "/v2/widgets/search?page=2", {"query": "widgets"}, ) + + +def test_auto_paging_passes_per_call_options_to_every_page(): + first = make_result(["one"], "/v2/widgets/search?page=2") + first._retrieve_params = {"query": "widgets"} + TestSearchResult.requests = [] + TestSearchResult.pages = [ + make_result(["two"], "/v2/widgets/search?page=3"), + make_result(["three"], None), + ] + + assert list(first.auto_paging_iter(api_key="worker-key-sentinel")) == [ + "one", + "two", + "three", + ] + assert [body for _, _, body, _ in TestSearchResult.requests] == [ + {"query": "widgets", "api_key": "worker-key-sentinel"}, + {"query": "widgets", "api_key": "worker-key-sentinel"}, + ]