diff --git a/src/ipsdk/platform.py b/src/ipsdk/platform.py index 1af2feb..1e12fec 100644 --- a/src/ipsdk/platform.py +++ b/src/ipsdk/platform.py @@ -220,8 +220,6 @@ async def create_workflow(name): print(f"Request failed with status {response.status_code}") """ -from typing import Any - import httpx from . import connection @@ -229,52 +227,17 @@ async def create_workflow(name): from . import jsonutils from . import logging +# OAuth constants +_OAUTH_HEADERS: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"} +_OAUTH_PATH: str = "/oauth/token" -@logging.trace -def _make_oauth_headers() -> dict[str, str]: - """ - Create HTTP headers for OAuth token request. - - Returns the headers dict required for OAuth client credentials - token requests. The Content-Type is set to application/x-www-form-urlencoded - as required by the OAuth 2.0 specification. - - Args: - None - - Returns: - dict[str, str]: Headers dict with Content-Type for OAuth requests - - Raises: - None - """ - return {"Content-Type": "application/x-www-form-urlencoded"} - - -@logging.trace -def _make_oauth_path() -> str: - """ - Get the URL path for OAuth token endpoint. - - Returns the API path used to request OAuth access tokens using - client credentials grant type. - - Args: - None - - Returns: - str: The OAuth token endpoint path - - Raises: - None - """ - return "/oauth/token" +# Basic authentication constants +_BASICAUTH_PATH: str = "/login" @logging.trace def _make_oauth_body(client_id: str, client_secret: str) -> dict[str, str]: - """ - Create request body for OAuth client credentials authentication. + """Create request body for OAuth client credentials authentication. Constructs the form data required for OAuth 2.0 client credentials grant type. The body includes grant_type, client_id, and client_secret @@ -286,9 +249,6 @@ def _make_oauth_body(client_id: str, client_secret: str) -> dict[str, str]: Returns: dict[str, str]: Form data dict for OAuth token request - - Raises: - None """ return { "grant_type": "client_credentials", @@ -299,8 +259,7 @@ def _make_oauth_body(client_id: str, client_secret: str) -> dict[str, str]: @logging.trace def _make_basicauth_body(user: str, password: str) -> dict[str, dict[str, str]]: - """ - Create request body for basic username/password authentication. + """Create request body for basic username/password authentication. Constructs the JSON request body required for basic authentication to Platform. The body contains a nested user object with username @@ -312,9 +271,6 @@ def _make_basicauth_body(user: str, password: str) -> dict[str, dict[str, str]]: Returns: dict[str, dict[str, str]]: JSON body dict for basic auth request - - Raises: - None """ return { "user": { @@ -324,29 +280,32 @@ def _make_basicauth_body(user: str, password: str) -> dict[str, dict[str, str]]: } -@logging.trace -def _make_basicauth_path() -> str: - """ - Get the URL path for basic authentication endpoint. - - Returns the API path used for username/password authentication - to Platform. - - Args: - None - - Returns: - str: The basic authentication endpoint path - - Raises: - None - """ - return "/login" - class AuthMixin: - """ - Authorization mixin for authenticating to Itential Platform. + """Authorization mixin for Itential Platform synchronous authentication. + + This mixin provides authentication methods for Platform connections, + supporting both OAuth 2.0 client credentials and basic username/password + authentication. It's designed to be mixed with Connection to create + the Platform class. + + The mixin automatically selects the appropriate authentication method + based on which credentials are provided: + - OAuth: Requires client_id and client_secret + - Basic: Requires user and password + + Attributes: + user (str | None): Username for basic authentication (from ConnectionBase) + password (str | None): Password for basic authentication (from ConnectionBase) + client_id (str | None): OAuth client ID (from ConnectionBase) + client_secret (str | None): OAuth client secret (from ConnectionBase) + client (httpx.Client): HTTP client instance for making requests + token (str | None): Access token for OAuth authentication + + Methods: + authenticate(): Main authentication entry point + authenticate_basicauth(): Basic username/password authentication + authenticate_oauth(): OAuth 2.0 client credentials authentication """ # Attributes that should be provided by ConnectionBase @@ -359,13 +318,25 @@ class AuthMixin: @logging.trace def authenticate(self) -> None: - """ - Provides the authentication function for authenticating to the server + """Authenticate to Itential Platform using configured credentials. + + Automatically selects OAuth or basic authentication based on which + credentials are provided (client_id/client_secret or user/password). + Authentication is performed on the first API request and the session + or token is maintained for subsequent requests. + + Returns: + None + + Raises: + IpsdkError: If no valid authentication credentials are provided + HTTPStatusError: If authentication request fails with HTTP error + RequestError: If authentication request fails due to network error """ if self.client_id is not None and self.client_secret is not None: self.authenticate_oauth() elif self.user is not None and self.password is not None: - self.authenticate_user() + self.authenticate_basicauth() else: msg = ( "No valid authentication credentials provided. " @@ -376,9 +347,21 @@ def authenticate(self) -> None: logging.info("client connection successfully authenticated") @logging.trace - def authenticate_user(self) -> None: - """ - Performs authentication for basic authorization + def authenticate_basicauth(self) -> None: + """Perform basic username/password authentication to Platform. + + Authenticates to Itential Platform using username and password credentials. + Sends credentials to the /login endpoint and maintains a session via cookies + for subsequent requests. + + Returns: + None + + Raises: + IpsdkError: If username or password is missing + HTTPStatusError: If server returns HTTP error status (401, 403, etc.) + RequestError: If network/connection error occurs (timeout, DNS + failure, etc.) """ logging.info("Attempting to perform basic authentication") @@ -387,7 +370,7 @@ def authenticate_user(self) -> None: raise exceptions.IpsdkError(msg) data = _make_basicauth_body(self.user, self.password) - path = _make_basicauth_path() + path = _BASICAUTH_PATH try: res = self.client.post(path, json=data) @@ -395,16 +378,29 @@ def authenticate_user(self) -> None: except httpx.HTTPStatusError as exc: logging.exception(exc) - raise exceptions.HTTPStatusError(exc.message, exc) + raise exceptions.HTTPStatusError(exc) except httpx.RequestError as exc: - logging.exception(exc.message, exc) - raise exceptions.RequestError(exc.message, exc) + logging.exception(exc) + raise exceptions.RequestError(exc) @logging.trace def authenticate_oauth(self) -> None: - """ - Performs authentication for OAuth client credentials + """Perform OAuth 2.0 client credentials authentication to Platform. + + Authenticates to Itential Platform using OAuth 2.0 client credentials flow. + Requests an access token from the /oauth/token endpoint using client_id + and client_secret. The token is stored in self.token and included in + subsequent requests as a Bearer token in the Authorization header. + + Returns: + None + + Raises: + IpsdkError: If client_id or client_secret is missing + HTTPStatusError: If server returns HTTP error status (401, 403, etc.) + RequestError: If network/connection error occurs (timeout, DNS + failure, etc.) """ logging.info("Attempting to perform oauth authentication") @@ -413,8 +409,8 @@ def authenticate_oauth(self) -> None: raise exceptions.IpsdkError(msg) data = _make_oauth_body(self.client_id, self.client_secret) - headers = _make_oauth_headers() - path = _make_oauth_path() + headers = _OAUTH_HEADERS + path = _OAUTH_PATH try: res = self.client.post(path, headers=headers, data=data) @@ -431,16 +427,38 @@ def authenticate_oauth(self) -> None: except httpx.HTTPStatusError as exc: logging.exception(exc) - raise exceptions.HTTPStatusError(exc.message, exc) + raise exceptions.HTTPStatusError(exc) except httpx.RequestError as exc: - logging.exception(exc.message, exc) - raise exceptions.RequestError(exc.message, exc) + logging.exception(exc) + raise exceptions.RequestError(exc) class AsyncAuthMixin: - """ - Platform is a HTTP connection to Itential Platform + """Authorization mixin for Itential Platform asynchronous authentication. + + This mixin provides async authentication methods for Platform connections, + supporting both OAuth 2.0 client credentials and basic username/password + authentication with async/await support. It's designed to be mixed with + AsyncConnection to create the AsyncPlatform class. + + The mixin automatically selects the appropriate authentication method + based on which credentials are provided: + - OAuth: Requires client_id and client_secret + - Basic: Requires user and password + + Attributes: + user (str | None): Username for basic authentication (from ConnectionBase) + password (str | None): Password for basic authentication (from ConnectionBase) + client_id (str | None): OAuth client ID (from ConnectionBase) + client_secret (str | None): OAuth client secret (from ConnectionBase) + client (httpx.AsyncClient): Async HTTP client instance for making requests + token (str | None): Access token for OAuth authentication + + Methods: + authenticate(): Main async authentication entry point + authenticate_basicauth(): Async basic username/password authentication + authenticate_oauth(): Async OAuth 2.0 client credentials authentication """ # Attributes that should be provided by ConnectionBase @@ -453,8 +471,20 @@ class AsyncAuthMixin: @logging.trace async def authenticate(self) -> None: - """ - Provides the authentication function for authenticating to the server + """Asynchronously authenticate to Platform using configured credentials. + + Automatically selects OAuth or basic authentication based on which + credentials are provided (client_id/client_secret or user/password). + Authentication is performed on the first API request and the session + or token is maintained for subsequent requests. + + Returns: + None + + Raises: + IpsdkError: If no valid authentication credentials are provided + HTTPStatusError: If authentication request fails with HTTP error + RequestError: If authentication request fails due to network error """ if self.client_id is not None and self.client_secret is not None: await self.authenticate_oauth() @@ -473,8 +503,20 @@ async def authenticate(self) -> None: @logging.trace async def authenticate_basicauth(self) -> None: - """ - Performs authentication for basic authorization + """Asynchronously perform basic username/password authentication to Platform. + + Authenticates to Itential Platform using username and password credentials. + Sends credentials to the /login endpoint and maintains a session via cookies + for subsequent requests. Uses async/await for non-blocking operation. + + Returns: + None + + Raises: + IpsdkError: If username or password is missing + HTTPStatusError: If server returns HTTP error status (401, 403, etc.) + RequestError: If network/connection error occurs (timeout, DNS + failure, etc.) """ logging.info("Attempting to perform basic authentication") @@ -483,7 +525,7 @@ async def authenticate_basicauth(self) -> None: raise exceptions.IpsdkError(msg) data = _make_basicauth_body(self.user, self.password) - path = _make_basicauth_path() + path = _BASICAUTH_PATH try: res = await self.client.post(path, json=data) @@ -491,16 +533,30 @@ async def authenticate_basicauth(self) -> None: except httpx.HTTPStatusError as exc: logging.exception(exc) - raise exceptions.HTTPStatusError(exc.message, exc) + raise exceptions.HTTPStatusError(exc) except httpx.RequestError as exc: - logging.exception(exc.message, exc) - raise exceptions.RequestError(exc.message, exc) + logging.exception(exc) + raise exceptions.RequestError(exc) @logging.trace async def authenticate_oauth(self) -> None: - """ - Performs authentication for OAuth client credentials + """Asynchronously perform OAuth 2.0 client credentials authentication. + + Authenticates to Itential Platform using OAuth 2.0 client credentials flow. + Requests an access token from the /oauth/token endpoint using client_id + and client_secret. The token is stored in self.token and included in + subsequent requests as a Bearer token in the Authorization header. + Uses async/await for non-blocking operation. + + Returns: + None + + Raises: + IpsdkError: If client_id or client_secret is missing + HTTPStatusError: If server returns HTTP error status (401, 403, etc.) + RequestError: If network/connection error occurs (timeout, DNS + failure, etc.) """ logging.info("Attempting to perform oauth authentication") @@ -509,20 +565,29 @@ async def authenticate_oauth(self) -> None: raise exceptions.IpsdkError(msg) data = _make_oauth_body(self.client_id, self.client_secret) - headers = _make_oauth_headers() - path = _make_oauth_path() + headers = _OAUTH_HEADERS + path = _OAUTH_PATH try: res = await self.client.post(path, headers=headers, data=data) res.raise_for_status() + # Parse the response to extract the token + response_data = jsonutils.loads(res.text) + if isinstance(response_data, dict): + access_token = response_data.get("access_token") + else: + access_token = None + + self.token = access_token + except httpx.HTTPStatusError as exc: logging.exception(exc) - raise exceptions.HTTPStatusError(exc.message, exc) + raise exceptions.HTTPStatusError(exc) except httpx.RequestError as exc: - logging.exception(exc.message, exc) - raise exceptions.RequestError(exc.message, exc) + logging.exception(exc) + raise exceptions.RequestError(exc) # Define type aliases for the dynamically created classes @@ -546,7 +611,7 @@ def platform_factory( client_secret: str | None = None, timeout: int = 30, want_async: bool = False, -) -> Any: +) -> Platform | AsyncPlatform: """ Create a new instance of a Platform connection. @@ -594,7 +659,7 @@ def platform_factory( Returns: Platform: An initialized Platform connection instance. """ - factory = AsyncPlatform if want_async is True else Platform + factory = AsyncPlatform if want_async else Platform return factory( host=host, port=port, diff --git a/tests/test_platform.py b/tests/test_platform.py index 70f744d..8f478f7 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -12,16 +12,16 @@ from ipsdk.connection import AsyncConnection from ipsdk.connection import Connection from ipsdk.http import Response +from ipsdk.platform import _BASICAUTH_PATH +from ipsdk.platform import _OAUTH_HEADERS +from ipsdk.platform import _OAUTH_PATH from ipsdk.platform import AsyncAuthMixin from ipsdk.platform import AsyncPlatformType from ipsdk.platform import AuthMixin from ipsdk.platform import Platform from ipsdk.platform import PlatformType from ipsdk.platform import _make_basicauth_body -from ipsdk.platform import _make_basicauth_path from ipsdk.platform import _make_oauth_body -from ipsdk.platform import _make_oauth_headers -from ipsdk.platform import _make_oauth_path from ipsdk.platform import platform_factory # --------- Factory Tests --------- @@ -84,14 +84,13 @@ def test_platform_factory_oauth_only(): def test_make_oauth_headers(): - """Test _make_oauth_headers utility function.""" - headers = _make_oauth_headers() - assert headers == {"Content-Type": "application/x-www-form-urlencoded"} + """Test _OAUTH_HEADERS constant.""" + assert _OAUTH_HEADERS == {"Content-Type": "application/x-www-form-urlencoded"} def test_make_oauth_path(): - """Test _make_oauth_path utility function.""" - assert _make_oauth_path() == "/oauth/token" + """Test _OAUTH_PATH constant.""" + assert _OAUTH_PATH == "/oauth/token" def test_make_oauth_body(): @@ -124,8 +123,8 @@ def test_make_basicauth_body(): def test_make_basicauth_path(): - """Test _make_basicauth_path utility function.""" - assert _make_basicauth_path() == "/login" + """Test _BASICAUTH_PATH constant.""" + assert _BASICAUTH_PATH == "/login" # --------- Sync AuthMixin Tests --------- @@ -162,7 +161,7 @@ def test_authenticate_oauth_success(): def test_authenticate_user_success(): - """Test AuthMixin.authenticate_user successful authentication.""" + """Test AuthMixin.authenticate_basicauth successful authentication.""" mixin = AuthMixin() mixin.user = "testuser" mixin.password = "testpass" @@ -173,7 +172,7 @@ def test_authenticate_user_success(): mock_response.raise_for_status.return_value = None mixin.client.post.return_value = mock_response - mixin.authenticate_user() + mixin.authenticate_basicauth() mixin.client.post.assert_called_once_with( "/login", json={"user": {"username": "testuser", "password": "testpass"}} @@ -469,7 +468,7 @@ def test_authenticate_oauth_non_dict_response(): def test_authenticate_oauth_http_status_error(): - """Test authenticate_oauth raises TypeError due to source bug on HTTP error.""" + """Test authenticate_oauth raises HTTPStatusError on HTTP error.""" mixin = AuthMixin() mixin.client_id = "test_id" mixin.client_secret = "test_secret" @@ -482,19 +481,17 @@ def test_authenticate_oauth_http_status_error(): http_exc = httpx.HTTPStatusError( "401 Unauthorized", request=mock_request, response=mock_response ) - http_exc.message = "401 Unauthorized" mock_post_response = Mock() mock_post_response.raise_for_status.side_effect = http_exc mixin.client.post.return_value = mock_post_response - # Source has bug: passes exc.message but HTTPStatusError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.HTTPStatusError): mixin.authenticate_oauth() def test_authenticate_oauth_request_error(): - """Test authenticate_oauth raises TypeError due to source bug on network error.""" + """Test authenticate_oauth raises RequestError on network error.""" mixin = AuthMixin() mixin.client_id = "test_id" mixin.client_secret = "test_secret" @@ -503,17 +500,15 @@ def test_authenticate_oauth_request_error(): # Mock RequestError mock_request = Mock() request_exc = httpx.RequestError("Network error", request=mock_request) - request_exc.message = "Network error" mixin.client.post.side_effect = request_exc - # Source has bug: passes exc.message but RequestError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.RequestError): mixin.authenticate_oauth() def test_authenticate_user_http_status_error(): - """Test authenticate_user raises TypeError due to source bug on HTTP error.""" + """Test authenticate_basicauth raises HTTPStatusError on HTTP error.""" mixin = AuthMixin() mixin.user = "testuser" mixin.password = "testpass" @@ -526,19 +521,17 @@ def test_authenticate_user_http_status_error(): http_exc = httpx.HTTPStatusError( "403 Forbidden", request=mock_request, response=mock_response ) - http_exc.message = "403 Forbidden" mock_post_response = Mock() mock_post_response.raise_for_status.side_effect = http_exc mixin.client.post.return_value = mock_post_response - # Source has bug: passes exc.message but HTTPStatusError only takes exc - with pytest.raises(TypeError): - mixin.authenticate_user() + with pytest.raises(exceptions.HTTPStatusError): + mixin.authenticate_basicauth() def test_authenticate_user_request_error(): - """Test authenticate_user raises TypeError due to source bug on network error.""" + """Test authenticate_basicauth raises RequestError on network error.""" mixin = AuthMixin() mixin.user = "testuser" mixin.password = "testpass" @@ -547,13 +540,11 @@ def test_authenticate_user_request_error(): # Mock RequestError mock_request = Mock() request_exc = httpx.RequestError("Connection timeout", request=mock_request) - request_exc.message = "Connection timeout" mixin.client.post.side_effect = request_exc - # Source has bug: passes exc.message but RequestError only takes exc - with pytest.raises(TypeError): - mixin.authenticate_user() + with pytest.raises(exceptions.RequestError): + mixin.authenticate_basicauth() def test_authenticate_no_credentials_error(): @@ -634,7 +625,7 @@ async def test_async_authenticate_oauth_success(): @pytest.mark.asyncio async def test_async_authenticate_oauth_http_status_error(): - """Test async authenticate_oauth raises TypeError due to source bug.""" + """Test async authenticate_oauth raises HTTPStatusError on HTTP error.""" mixin = AsyncAuthMixin() mixin.client_id = "test_id" mixin.client_secret = "test_secret" @@ -647,20 +638,18 @@ async def test_async_authenticate_oauth_http_status_error(): http_exc = httpx.HTTPStatusError( "401 Unauthorized", request=mock_request, response=mock_response ) - http_exc.message = "401 Unauthorized" mock_post_response = Mock() mock_post_response.raise_for_status.side_effect = http_exc mixin.client.post.return_value = mock_post_response - # Source has bug: passes exc.message but HTTPStatusError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.HTTPStatusError): await mixin.authenticate_oauth() @pytest.mark.asyncio async def test_async_authenticate_oauth_request_error(): - """Test async authenticate_oauth raises TypeError due to bug.""" + """Test async authenticate_oauth raises RequestError on network error.""" mixin = AsyncAuthMixin() mixin.client_id = "test_id" mixin.client_secret = "test_secret" @@ -669,18 +658,16 @@ async def test_async_authenticate_oauth_request_error(): # Mock RequestError mock_request = Mock() request_exc = httpx.RequestError("Network failure", request=mock_request) - request_exc.message = "Network failure" mixin.client.post.side_effect = request_exc - # Source has bug: passes exc.message but RequestError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.RequestError): await mixin.authenticate_oauth() @pytest.mark.asyncio async def test_async_authenticate_basicauth_http_status_error(): - """Test async authenticate_basicauth raises TypeError due to bug.""" + """Test async authenticate_basicauth raises HTTPStatusError on HTTP error.""" mixin = AsyncAuthMixin() mixin.user = "testuser" mixin.password = "testpass" @@ -693,20 +680,18 @@ async def test_async_authenticate_basicauth_http_status_error(): http_exc = httpx.HTTPStatusError( "403 Forbidden", request=mock_request, response=mock_response ) - http_exc.message = "403 Forbidden" mock_post_response = Mock() mock_post_response.raise_for_status.side_effect = http_exc mixin.client.post.return_value = mock_post_response - # Source has bug: passes exc.message but HTTPStatusError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.HTTPStatusError): await mixin.authenticate_basicauth() @pytest.mark.asyncio async def test_async_authenticate_basicauth_request_error(): - """Test async authenticate_basicauth raises TypeError due to bug.""" + """Test async authenticate_basicauth raises RequestError on network error.""" mixin = AsyncAuthMixin() mixin.user = "testuser" mixin.password = "testpass" @@ -715,12 +700,10 @@ async def test_async_authenticate_basicauth_request_error(): # Mock RequestError mock_request = Mock() request_exc = httpx.RequestError("Connection failed", request=mock_request) - request_exc.message = "Connection failed" mixin.client.post.side_effect = request_exc - # Source has bug: passes exc.message but RequestError only takes exc - with pytest.raises(TypeError): + with pytest.raises(exceptions.RequestError): await mixin.authenticate_basicauth() @@ -753,11 +736,16 @@ async def test_async_authenticate_oauth_path(): # Mock successful response mock_response = Mock() mock_response.status_code = 200 + mock_response.text = '{"access_token": "test_token_123"}' mock_response.raise_for_status = Mock() mixin.client.post.return_value = mock_response - await mixin.authenticate() + with patch( + "ipsdk.jsonutils.loads", return_value={"access_token": "test_token_123"} + ): + await mixin.authenticate() + assert mixin.token == "test_token_123" # Verify OAuth was called mixin.client.post.assert_awaited_once_with( "/oauth/token", @@ -780,7 +768,7 @@ def test_platform_mixin_inheritance(): # Should have AuthMixin methods assert hasattr(platform, "authenticate") assert hasattr(platform, "authenticate_oauth") - assert hasattr(platform, "authenticate_user") + assert hasattr(platform, "authenticate_basicauth") # Should have Connection methods assert hasattr(platform, "get")