From 9f9aa4f4ac0d0b9c5ef09af120171d9ab110c219 Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles" Date: Thu, 11 Jun 2026 12:55:24 -0600 Subject: [PATCH 1/3] feat(proxy): add proxy support to AmazonCreatorsApi Fixes two independent gaps that prevented routing traffic through an HTTP proxy: 1. AmazonCreatorsApi.__init__ now accepts a `proxy` URL parameter and passes it to ApiClient via a Configuration object. RESTClientObject already used urllib3.ProxyManager when configuration.proxy was set; this just wires the public API through to it. 2. OAuth2TokenManager.refresh_token() previously called requests.post() directly, bypassing any proxy configuration. It now creates a requests.Session and sets .proxies when a proxy URL is provided, ensuring token refresh on cold cache also routes through the proxy. Closes #149 Co-authored-by: Claude (claude-sonnet-4-6) --- amazon_creatorsapi/api.py | 8 +++ creatorsapi_python_sdk/api_client.py | 4 +- .../auth/oauth2_token_manager.py | 14 ++-- tests/amazon_creatorsapi/api_test.py | 28 ++++++++ .../oauth2_token_manager_test.py | 72 +++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/amazon_creatorsapi/oauth2_token_manager_test.py diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 70dc472..5c332d4 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -16,6 +16,7 @@ from amazon_creatorsapi.errors import ItemsNotFoundError from creatorsapi_python_sdk.api.default_api import DefaultApi from creatorsapi_python_sdk.api_client import ApiClient +from creatorsapi_python_sdk.configuration import Configuration from creatorsapi_python_sdk.exceptions import ApiException from creatorsapi_python_sdk.models.get_browse_nodes_request_content import ( GetBrowseNodesRequestContent, @@ -58,6 +59,8 @@ class AmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. throttling: Wait time in seconds between API calls. Defaults to 1 second. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to both regular API calls and OAuth2 token refresh. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -83,6 +86,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + proxy: str | None = None, ) -> None: """Initialize the Amazon Creators API client.""" self._credential_id = credential_id @@ -95,7 +99,11 @@ def __init__( # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + configuration = Configuration() + configuration.proxy = proxy + self._api_client = ApiClient( + configuration=configuration, credential_id=credential_id, credential_secret=credential_secret, version=version, diff --git a/creatorsapi_python_sdk/api_client.py b/creatorsapi_python_sdk/api_client.py index 41f2b17..26bc802 100644 --- a/creatorsapi_python_sdk/api_client.py +++ b/creatorsapi_python_sdk/api_client.py @@ -384,7 +384,9 @@ def call_api( self.credential_id, self.credential_secret, self.version, self.auth_endpoint ) - self._token_manager = OAuth2TokenManager(config) + proxy = self.configuration.proxy + proxies = {"http": proxy, "https": proxy} if proxy else None + self._token_manager = OAuth2TokenManager(config, proxies=proxies) # Get token (will use cached token if valid) token = self._token_manager.get_token() # Add Authorization headers - Version only for v2.x diff --git a/creatorsapi_python_sdk/auth/oauth2_token_manager.py b/creatorsapi_python_sdk/auth/oauth2_token_manager.py index 7a730fa..65b01d8 100644 --- a/creatorsapi_python_sdk/auth/oauth2_token_manager.py +++ b/creatorsapi_python_sdk/auth/oauth2_token_manager.py @@ -30,13 +30,15 @@ class OAuth2TokenManager: """Manages OAuth2 token lifecycle including acquisition, caching, and automatic refresh""" - def __init__(self, config): + def __init__(self, config, proxies=None): """ Creates an OAuth2TokenManager instance - + :param config: The OAuth2Config instance + :param proxies: Optional dict of proxy URLs, e.g. {"http": "http://proxy:3128", "https": "http://proxy:3128"} """ self.config = config + self.proxies = proxies self.access_token = None self.expires_at = None @@ -67,6 +69,10 @@ def refresh_token(self): :raises Exception: If token refresh fails """ try: + session = requests.Session() + if self.proxies: + session.proxies.update(self.proxies) + if self.config.is_lwa(): # LWA (v3.x) uses JSON body request_data = { @@ -76,7 +82,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/json'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), json=request_data, headers=headers @@ -90,7 +96,7 @@ def refresh_token(self): 'scope': self.config.get_scope() } headers = {'Content-Type': 'application/x-www-form-urlencoded'} - response = requests.post( + response = session.post( self.config.get_cognito_endpoint(), data=request_data, headers=headers diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c471f51..9ffb7b2 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -99,6 +99,34 @@ def test_init_no_country_or_marketplace(self) -> None: tag=self.tag, ) + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_with_proxy(self, mock_client: MagicMock) -> None: + """Test that proxy URL is passed through to ApiClient configuration.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + proxy=proxy_url, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertEqual(call_kwargs["configuration"].proxy, proxy_url) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_init_without_proxy(self, mock_client: MagicMock) -> None: + """Test that configuration.proxy is None when no proxy is provided.""" + AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + ) + call_kwargs = mock_client.call_args.kwargs + self.assertIsNone(call_kwargs["configuration"].proxy) + @mock.patch("amazon_creatorsapi.api.ApiClient") def test_throttling_disabled(self, _mock_client: MagicMock) -> None: """Test that API call is not delayed when throttling is 0.""" diff --git a/tests/amazon_creatorsapi/oauth2_token_manager_test.py b/tests/amazon_creatorsapi/oauth2_token_manager_test.py new file mode 100644 index 0000000..e0ece16 --- /dev/null +++ b/tests/amazon_creatorsapi/oauth2_token_manager_test.py @@ -0,0 +1,72 @@ +"""Unit tests for OAuth2TokenManager proxy support.""" + +from __future__ import annotations + +import unittest +from unittest import mock +from unittest.mock import MagicMock, patch + +from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config +from creatorsapi_python_sdk.auth.oauth2_token_manager import OAuth2TokenManager + + +def _make_config(version: str = "2.2") -> OAuth2Config: + return OAuth2Config( + credential_id="test_id", + credential_secret="test_secret", + version=version, + auth_endpoint=None, + ) + + +def _mock_token_response() -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"access_token": "tok123", "expires_in": 3600} + return resp + + +class TestOAuth2TokenManagerProxy(unittest.TestCase): + """Tests that OAuth2TokenManager routes token refresh through the proxy.""" + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """When proxies are provided, Session.proxies.update is called with them.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies) + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_no_proxy_skips_proxies_update(self, mock_session_cls: MagicMock) -> None: + """When no proxy is configured, Session.proxies.update is not called.""" + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config()) + manager.refresh_token() + + mock_session.proxies.update.assert_not_called() + + @patch("creatorsapi_python_sdk.auth.oauth2_token_manager.requests.Session") + def test_refresh_token_lwa_sets_proxies_on_session(self, mock_session_cls: MagicMock) -> None: + """Proxy is also applied for LWA (v3.x) token refresh.""" + proxy_url = "http://proxy.example.com:3128" + proxies = {"http": proxy_url, "https": proxy_url} + + mock_session = MagicMock() + mock_session.post.return_value = _mock_token_response() + mock_session_cls.return_value = mock_session + + manager = OAuth2TokenManager(_make_config(version="3.1"), proxies=proxies) + manager.refresh_token() + + mock_session.proxies.update.assert_called_once_with(proxies) From c1c38bcf8b683b4da3551ae54e153d0bee431cdd Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles (Mek)" Date: Wed, 24 Jun 2026 22:43:19 -0600 Subject: [PATCH 2/3] fix(proxy): extract credentials from proxy URL for urllib3 HTTPS CONNECT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit urllib3.ProxyManager ignores userinfo embedded in the proxy URL when establishing HTTPS CONNECT tunnels — it only reads the netloc for the tunnel destination, not the proxy auth headers. The result is a 407 Proxy Authentication Required on every HTTPS API call through an authenticated Squid proxy (e.g. IA's http-proxy.us.archive.org). Fix: before handing the URL to ProxyManager, check whether configuration.proxy_headers is already set (caller-supplied). If not, parse the URL, extract username:password, build the Proxy-Authorization header via urllib3.make_headers(proxy_basic_auth=) and strip the credentials from the URL. The full credentialed URL is preserved in configuration.proxy so the requests-based OAuth2 token leg (api_client.py) continues to work unchanged. Confirmed via proxy_test.py on ol-home0: Approach A (URL-only): 407 Proxy Authentication Required Approach B (proxy_headers): 400 from creatorsapi.amazon (connected) --- creatorsapi_python_sdk/rest.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/creatorsapi_python_sdk/rest.py b/creatorsapi_python_sdk/rest.py index d41d019..be7c135 100644 --- a/creatorsapi_python_sdk/rest.py +++ b/creatorsapi_python_sdk/rest.py @@ -110,8 +110,28 @@ def __init__(self, configuration) -> None: pool_args["headers"] = configuration.proxy_headers self.pool_manager = SOCKSProxyManager(**pool_args) else: - pool_args["proxy_url"] = configuration.proxy - pool_args["proxy_headers"] = configuration.proxy_headers + proxy_url = configuration.proxy + proxy_headers = configuration.proxy_headers + # urllib3 ProxyManager ignores credentials embedded in the + # proxy URL for HTTPS CONNECT tunneling — they must be passed + # via proxy_headers instead. Extract them here so callers can + # pass a plain "http://user:pass@host:port" URL and get correct + # CONNECT auth without any extra configuration. + if proxy_headers is None: + from urllib.parse import urlparse + _parsed = urlparse(proxy_url) + if _parsed.username: + proxy_headers = urllib3.make_headers( + proxy_basic_auth=( + f"{_parsed.username}:{_parsed.password}" + ) + ) + proxy_url = ( + f"{_parsed.scheme}://" + f"{_parsed.hostname}:{_parsed.port}" + ) + pool_args["proxy_url"] = proxy_url + pool_args["proxy_headers"] = proxy_headers self.pool_manager = urllib3.ProxyManager(**pool_args) else: self.pool_manager = urllib3.PoolManager(**pool_args) From 98c390cd8cdabf051f4b3e8912adf5a6d06439e0 Mon Sep 17 00:00:00 2001 From: RayBB Date: Tue, 11 Aug 2026 09:59:23 -0700 Subject: [PATCH 3/3] feat(proxy): add proxy support to async Creators API (aio) The sync AmazonCreatorsApi gained proxy support (9f9aa4f, c1c38bc) but the async module never received it, making AsyncAmazonCreatorsApi unusable in environments that require routing all traffic through an HTTP proxy (e.g. Open Library production hosts with no direct internet access). Thread an optional proxy URL through AsyncAmazonCreatorsApi, AsyncHttpClient, and AsyncOAuth2TokenManager so both API calls and OAuth2 token refresh route through the proxy. httpx natively supports credentials embedded in the proxy URL, so no header extraction is required. Empty-string proxies are normalized to None for parity with the sync path. Mirrors the sync proxy tests: proxy is stored and passed to httpx.AsyncClient in context-manager and standalone modes, and token refresh routes through the proxy for both Cognito (v2.x) and LWA (v3.x) flows. --- amazon_creatorsapi/aio/api.py | 11 ++- amazon_creatorsapi/aio/auth.py | 8 +- amazon_creatorsapi/aio/client.py | 8 ++ tests/amazon_creatorsapi/aio/api_test.py | 96 +++++++++++++++++++++ tests/amazon_creatorsapi/aio/auth_test.py | 84 ++++++++++++++++++ tests/amazon_creatorsapi/aio/client_test.py | 62 +++++++++++++ 6 files changed, 266 insertions(+), 3 deletions(-) diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 19e15bf..06731c4 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -104,6 +104,9 @@ class AsyncAmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. throttling: Wait time in seconds between API calls. Defaults to 1 second. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to both API calls and OAuth2 token refresh (httpx handles + credentials embedded in the URL). Defaults to no proxy. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -121,6 +124,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + proxy: str | None = None, ) -> None: """Initialize the async Amazon Creators API client.""" # Validate version early to fail fast (before token manager initialization) @@ -139,10 +143,13 @@ def __init__( # HTTP client and token manager (initialized lazily or via context manager) self._http_client: AsyncHttpClient | None = None + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._token_manager = AsyncOAuth2TokenManager( credential_id=credential_id, credential_secret=credential_secret, version=version, + proxy=self._proxy, ) self._owns_client = False @@ -163,7 +170,7 @@ def _validate_version(self, version: str) -> None: async def __aenter__(self) -> Self: """Enter async context manager, creating a persistent HTTP client.""" - self._http_client = AsyncHttpClient(host=API_HOST) + self._http_client = AsyncHttpClient(host=API_HOST, proxy=self._proxy) await self._http_client.__aenter__() self._owns_client = True return self @@ -498,7 +505,7 @@ async def _make_request( if self._http_client is not None: response = await self._http_client.post(endpoint, headers, body) else: - async with AsyncHttpClient(host=API_HOST) as client: + async with AsyncHttpClient(host=API_HOST, proxy=self._proxy) as client: response = await client.post(endpoint, headers, body) # Handle errors diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index 1f3878f..9105dd0 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -55,6 +55,9 @@ class AsyncOAuth2TokenManager: credential_secret: OAuth2 credential secret. version: API version (determines auth endpoint). auth_endpoint: Optional custom auth endpoint URL. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + Applied to token refresh requests (httpx handles credentials + embedded in the URL). Defaults to no proxy. """ @@ -64,12 +67,15 @@ def __init__( credential_secret: str, version: str, auth_endpoint: str | None = None, + proxy: str | None = None, ) -> None: """Initialize the async OAuth2 token manager.""" self._credential_id = credential_id self._credential_secret = credential_secret self._version = version self._auth_endpoint = self._determine_auth_endpoint(version, auth_endpoint) + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._access_token: str | None = None self._expires_at: float | None = None @@ -186,7 +192,7 @@ async def refresh_token(self) -> str: } try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(proxy=self._proxy) as client: if self.is_lwa(): response = await client.post( self._auth_endpoint, diff --git a/amazon_creatorsapi/aio/client.py b/amazon_creatorsapi/aio/client.py index 8ec143b..c7d5615 100644 --- a/amazon_creatorsapi/aio/client.py +++ b/amazon_creatorsapi/aio/client.py @@ -65,6 +65,9 @@ class AsyncHttpClient: Args: host: Base URL for API requests. Defaults to Amazon Creators API. timeout: Request timeout in seconds. Defaults to 30. + proxy: Optional HTTP proxy URL, e.g. ``"http://user:pass@proxy:3128"``. + httpx applies it to every request (and handles credentials + embedded in the URL). Defaults to no proxy. """ @@ -72,10 +75,13 @@ def __init__( self, host: str = DEFAULT_HOST, timeout: float = DEFAULT_TIMEOUT, + proxy: str | None = None, ) -> None: """Initialize the async HTTP client.""" self._host = host self._timeout = timeout + # Normalize empty string to None so httpx doesn't reject proxy="". + self._proxy = proxy or None self._client: httpx.AsyncClient | None = None self._owns_client = False @@ -85,6 +91,7 @@ async def __aenter__(self) -> Self: base_url=self._host, timeout=self._timeout, headers={"User-Agent": USER_AGENT}, + proxy=self._proxy, ) self._owns_client = True return self @@ -132,6 +139,7 @@ async def post( async with httpx.AsyncClient( base_url=self._host, timeout=self._timeout, + proxy=self._proxy, ) as client: response = await client.post( path, diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 0427d55..4d03535 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -82,6 +82,37 @@ def test_accepts_lwa_version(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.marketplace, "www.amazon.com") + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_init_with_proxy(self, mock_token_manager: MagicMock) -> None: + """Test proxy URL is passed through to the token manager.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + proxy=proxy_url, + ) + + self.assertEqual(api._proxy, proxy_url) + call_kwargs = mock_token_manager.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_init_without_proxy(self, mock_token_manager: MagicMock) -> None: + """Test token manager receives proxy=None when not provided.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + + call_kwargs = mock_token_manager.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_raises_error_when_no_country_or_marketplace( self, mock_token_manager: MagicMock @@ -157,6 +188,31 @@ async def test_context_manager_creates_and_closes_client( mock_client.__aexit__.assert_called_once() + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_context_manager_passes_proxy_to_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test context manager passes proxy to AsyncHttpClient.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_client = AsyncMock() + mock_http_client_class.return_value = mock_client + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + proxy=proxy_url, + ) as api: + self.assertEqual(api._proxy, proxy_url) + + call_kwargs = mock_http_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") async def test_context_manager_exit_without_client( self, @@ -1265,6 +1321,46 @@ async def test_request_without_context_manager( self.assertEqual(len(items), 1) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_without_context_manager_passes_proxy( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test standalone request creates temp client with proxy.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + proxy=proxy_url, + ) + + items = await api.get_items(["B0DLFMFBJW"]) + + self.assertEqual(len(items), 1) + call_kwargs = mock_http_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") async def test_request_uses_v2_authorization_header( diff --git a/tests/amazon_creatorsapi/aio/auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py index b0bd456..9f27da2 100644 --- a/tests/amazon_creatorsapi/aio/auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -213,6 +213,90 @@ async def test_refreshes_token_when_expired( self.assertIsNotNone(manager._expires_at) +class TestAsyncOAuth2TokenManagerProxy(unittest.IsolatedAsyncioTestCase): + """Tests that token refresh routes through the configured proxy.""" + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_sets_proxy_on_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """When a proxy is provided, httpx.AsyncClient is created with it.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager( + "test_id", "test_secret", "2.2", proxy=proxy_url + ) + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_no_proxy( + self, mock_async_client_class: MagicMock + ) -> None: + """When no proxy is configured, httpx.AsyncClient gets proxy=None.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + + @patch("amazon_creatorsapi.aio.auth.httpx.AsyncClient") + async def test_refresh_token_lwa_sets_proxy_on_client( + self, + mock_async_client_class: MagicMock, + ) -> None: + """Proxy is also applied for LWA (v3.x) token refresh.""" + proxy_url = "http://proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "tok123", + "expires_in": 3600, + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_async_client_class.return_value = mock_client + + manager = AsyncOAuth2TokenManager( + "test_id", "test_secret", "3.1", proxy=proxy_url + ) + + await manager.refresh_token() + + call_kwargs = mock_async_client_class.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + class TestAsyncOAuth2TokenManagerRefreshToken(unittest.IsolatedAsyncioTestCase): """Tests for refresh_token() method.""" diff --git a/tests/amazon_creatorsapi/aio/client_test.py b/tests/amazon_creatorsapi/aio/client_test.py index 3c8a605..92a37df 100644 --- a/tests/amazon_creatorsapi/aio/client_test.py +++ b/tests/amazon_creatorsapi/aio/client_test.py @@ -32,6 +32,17 @@ async def test_init_custom(self) -> None: self.assertEqual(client._host, host) self.assertEqual(client._timeout, timeout) + async def test_init_with_proxy(self) -> None: + """Test proxy is stored on the client.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + client = AsyncHttpClient(proxy=proxy_url) + self.assertEqual(client._proxy, proxy_url) + + async def test_init_without_proxy(self) -> None: + """Test proxy is None when not provided.""" + client = AsyncHttpClient() + self.assertIsNone(client._proxy) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_context_manager(self, mock_client_cls: MagicMock) -> None: """Test context manager creates and closes client.""" @@ -47,6 +58,33 @@ async def test_context_manager(self, mock_client_cls: MagicMock) -> None: self.assertFalse(client._owns_client) self.assertIsNone(client._client) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_context_manager_passes_proxy( + self, mock_client_cls: MagicMock + ) -> None: + """Test context manager passes proxy to httpx.AsyncClient.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_client_instance = AsyncMock() + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient(proxy=proxy_url) as client: + self.assertEqual(client._proxy, proxy_url) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_context_manager_no_proxy(self, mock_client_cls: MagicMock) -> None: + """Test context manager passes proxy=None when not provided.""" + mock_client_instance = AsyncMock() + mock_client_cls.return_value = mock_client_instance + + async with AsyncHttpClient() as client: + self.assertIsNone(client._proxy) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertIsNone(call_kwargs["proxy"]) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_info_logging_context_manager( self, mock_client_cls: MagicMock @@ -89,6 +127,30 @@ async def test_post_without_context_manager( mock_client_instance.__aenter__.assert_called() mock_client_instance.__aexit__.assert_called() + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") + async def test_post_without_context_manager_passes_proxy( + self, mock_client_cls: MagicMock + ) -> None: + """Test standalone post passes proxy to temporary httpx client.""" + proxy_url = "http://user:pass@proxy.example.com:3128" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b"{}" + mock_response.text = "{}" + + mock_client_instance = AsyncMock() + mock_client_instance.post.return_value = mock_response + mock_client_instance.__aenter__.return_value = mock_client_instance + mock_client_instance.__aexit__.return_value = None + mock_client_cls.return_value = mock_client_instance + + client = AsyncHttpClient(proxy=proxy_url) + await client.post("/test", {}, {}) + + call_kwargs = mock_client_cls.call_args.kwargs + self.assertEqual(call_kwargs["proxy"], proxy_url) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_post_with_context_manager(self, mock_client_cls: MagicMock) -> None: """Test post request reuses existing client."""