diff --git a/sdks/implementations/python/tests/test_client.py b/sdks/implementations/python/tests/test_client.py new file mode 100644 index 000000000..7349d389e --- /dev/null +++ b/sdks/implementations/python/tests/test_client.py @@ -0,0 +1,322 @@ +"""Tests for the sync and async HTTP client classes.""" + +from __future__ import annotations + +import asyncio +import re +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import respx + +from stack_auth._client import AsyncAPIClient, BaseAPIClient, SyncAPIClient +from stack_auth._version import __version__ +from stack_auth.errors import AuthenticationError, NotFoundError, StackAuthError + + +# --------------------------------------------------------------------------- +# Construction tests +# --------------------------------------------------------------------------- + + +class TestClientConstruction: + def test_sync_client_constructs(self) -> None: + client = SyncAPIClient(project_id="proj", secret_server_key="sk") + assert client is not None + assert isinstance(client, SyncAPIClient) + + def test_async_client_constructs(self) -> None: + client = AsyncAPIClient(project_id="proj", secret_server_key="sk") + assert client is not None + assert isinstance(client, AsyncAPIClient) + + +# --------------------------------------------------------------------------- +# Header tests +# --------------------------------------------------------------------------- + + +class TestBuildHeaders: + def setup_method(self) -> None: + self.client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret") + + def test_project_id_header(self) -> None: + headers = self.client._build_headers() + assert headers["x-stack-project-id"] == "proj_123" + + def test_access_type_header(self) -> None: + headers = self.client._build_headers() + assert headers["x-stack-access-type"] == "server" + + def test_secret_server_key_header(self) -> None: + headers = self.client._build_headers() + assert headers["x-stack-secret-server-key"] == "sk_secret" + + def test_client_version_header(self) -> None: + headers = self.client._build_headers() + assert headers["x-stack-client-version"] == f"python@{__version__}" + + def test_override_error_status_header(self) -> None: + headers = self.client._build_headers() + assert headers["x-stack-override-error-status"] == "true" + + def test_random_nonce_header_is_uuid(self) -> None: + headers = self.client._build_headers() + nonce = headers["x-stack-random-nonce"] + # UUID v4 pattern + uuid_pattern = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + re.IGNORECASE, + ) + assert uuid_pattern.match(nonce), f"Nonce {nonce!r} is not a valid UUID v4" + + +# --------------------------------------------------------------------------- +# URL building tests +# --------------------------------------------------------------------------- + + +class TestBuildUrl: + def test_default_base_url(self) -> None: + client = SyncAPIClient(project_id="p", secret_server_key="s") + assert client._build_url("/users") == "https://api.stack-auth.com/api/v1/users" + + def test_custom_base_url(self) -> None: + client = SyncAPIClient(project_id="p", secret_server_key="s", base_url="https://custom.host") + assert client._build_url("/users") == "https://custom.host/api/v1/users" + + +# --------------------------------------------------------------------------- +# Sync request tests +# --------------------------------------------------------------------------- + + +class TestSyncRequest: + @respx.mock + def test_get_returns_json(self) -> None: + route = respx.get("https://api.stack-auth.com/api/v1/users").mock( + return_value=httpx.Response( + 200, + json={"id": "user_1"}, + headers={"x-stack-actual-status": "200"}, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + result = client.request("GET", "/users") + assert result == {"id": "user_1"} + + @respx.mock + def test_known_error_raises_not_found(self) -> None: + respx.get("https://api.stack-auth.com/api/v1/users/123").mock( + return_value=httpx.Response( + 200, + json={"code": "USER_NOT_FOUND", "message": "User not found"}, + headers={ + "x-stack-actual-status": "400", + "x-stack-known-error": "USER_NOT_FOUND", + }, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(NotFoundError) as exc_info: + client.request("GET", "/users/123") + assert exc_info.value.code == "USER_NOT_FOUND" + + @respx.mock + def test_known_error_raises_authentication_error(self) -> None: + respx.get("https://api.stack-auth.com/api/v1/me").mock( + return_value=httpx.Response( + 200, + json={"code": "INVALID_ACCESS_TOKEN", "message": "Bad token"}, + headers={ + "x-stack-actual-status": "400", + "x-stack-known-error": "INVALID_ACCESS_TOKEN", + }, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(AuthenticationError): + client.request("GET", "/me") + + @respx.mock + def test_http_error_on_unknown_status(self) -> None: + respx.get("https://api.stack-auth.com/api/v1/fail").mock( + return_value=httpx.Response( + 200, + json={}, + headers={"x-stack-actual-status": "500"}, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(StackAuthError) as exc_info: + client.request("GET", "/fail") + assert exc_info.value.code == "HTTP_ERROR" + + @respx.mock + def test_post_with_none_body_sends_empty_json(self) -> None: + route = respx.post("https://api.stack-auth.com/api/v1/items").mock( + return_value=httpx.Response( + 200, + json={"ok": True}, + headers={"x-stack-actual-status": "200"}, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + client.request("POST", "/items", body=None) + sent_request = route.calls[0].request + assert sent_request.content == b"{}" + + @respx.mock + def test_get_with_none_body_sends_no_body(self) -> None: + route = respx.get("https://api.stack-auth.com/api/v1/items").mock( + return_value=httpx.Response( + 200, + json={"ok": True}, + headers={"x-stack-actual-status": "200"}, + ) + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + client.request("GET", "/items", body=None) + sent_request = route.calls[0].request + assert sent_request.content == b"" + + +# --------------------------------------------------------------------------- +# Async request tests +# --------------------------------------------------------------------------- + + +class TestAsyncRequest: + @respx.mock + @pytest.mark.asyncio + async def test_get_returns_json(self) -> None: + respx.get("https://api.stack-auth.com/api/v1/users").mock( + return_value=httpx.Response( + 200, + json={"id": "user_1"}, + headers={"x-stack-actual-status": "200"}, + ) + ) + client = AsyncAPIClient(project_id="p", secret_server_key="s") + result = await client.request("GET", "/users") + assert result == {"id": "user_1"} + await client.aclose() + + +# --------------------------------------------------------------------------- +# Retry tests (sync) +# --------------------------------------------------------------------------- + + +class TestRetryLogic: + @respx.mock + @patch("time.sleep") + def test_get_retries_on_connect_error(self, mock_sleep: AsyncMock) -> None: + url = "https://api.stack-auth.com/api/v1/data" + route = respx.get(url).mock(side_effect=httpx.ConnectError("Connection refused")) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(httpx.ConnectError): + client.request("GET", "/data") + # 1 initial + 5 retries = 6 total attempts + assert route.call_count == 6 + + @respx.mock + @patch("time.sleep") + def test_post_does_not_retry_on_connect_error(self, mock_sleep: AsyncMock) -> None: + url = "https://api.stack-auth.com/api/v1/data" + route = respx.post(url).mock(side_effect=httpx.ConnectError("Connection refused")) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(httpx.ConnectError): + client.request("POST", "/data") + assert route.call_count == 1 + + @respx.mock + @patch("time.sleep") + def test_retry_exponential_backoff_delays(self, mock_sleep: AsyncMock) -> None: + url = "https://api.stack-auth.com/api/v1/data" + respx.get(url).mock(side_effect=httpx.ConnectError("fail")) + client = SyncAPIClient(project_id="p", secret_server_key="s") + with pytest.raises(httpx.ConnectError): + client.request("GET", "/data") + delays = [call.args[0] for call in mock_sleep.call_args_list] + assert delays == [1.0, 2.0, 4.0, 8.0, 16.0] + + @respx.mock + @patch("time.sleep") + def test_429_retries_with_retry_after(self, mock_sleep: AsyncMock) -> None: + url = "https://api.stack-auth.com/api/v1/data" + # First call: 429 with Retry-After, second call: success + respx.get(url).mock( + side_effect=[ + httpx.Response( + 200, + json={}, + headers={"x-stack-actual-status": "429", "Retry-After": "3"}, + ), + httpx.Response( + 200, + json={"ok": True}, + headers={"x-stack-actual-status": "200"}, + ), + ] + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + result = client.request("GET", "/data") + assert result == {"ok": True} + mock_sleep.assert_called_once_with(3.0) + + @respx.mock + @patch("time.sleep") + def test_429_without_retry_after_uses_backoff(self, mock_sleep: AsyncMock) -> None: + url = "https://api.stack-auth.com/api/v1/data" + respx.get(url).mock( + side_effect=[ + httpx.Response( + 200, + json={}, + headers={"x-stack-actual-status": "429"}, + ), + httpx.Response( + 200, + json={"ok": True}, + headers={"x-stack-actual-status": "200"}, + ), + ] + ) + client = SyncAPIClient(project_id="p", secret_server_key="s") + result = client.request("GET", "/data") + assert result == {"ok": True} + # First attempt is attempt=0, so backoff = 1.0 * (2 ** 0) = 1.0 + mock_sleep.assert_called_once_with(1.0) + + +# --------------------------------------------------------------------------- +# Context manager tests +# --------------------------------------------------------------------------- + + +class TestContextManager: + def test_sync_context_manager(self) -> None: + with SyncAPIClient(project_id="p", secret_server_key="s") as client: + assert isinstance(client, SyncAPIClient) + + @pytest.mark.asyncio + async def test_async_context_manager(self) -> None: + async with AsyncAPIClient(project_id="p", secret_server_key="s") as client: + assert isinstance(client, AsyncAPIClient) + + def test_sync_close(self) -> None: + client = SyncAPIClient(project_id="p", secret_server_key="s") + # Force client creation by accessing it + _ = client._get_client() + client.close() + # After close, internal client should be None + assert client._client is None + + @pytest.mark.asyncio + async def test_async_aclose(self) -> None: + client = AsyncAPIClient(project_id="p", secret_server_key="s") + _ = client._get_client() + await client.aclose() + assert client._client is None