From 5280c105194a3a01b80c7425549d6509bef21c1d Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Tue, 16 Jun 2026 15:50:50 +0100 Subject: [PATCH] feat(auth): add generic authentication provider system [MPT-21532] Introduce an Authentication provider abstraction on top of httpx.Auth and rework the HTTP clients to use it, so authentication is no longer hardcoded to a single long-lived bearer token. Providers live in a dedicated mpt_api_client.auth package. Two providers are included: - BearerTokenAuthentication (auth.base): single long-lived bearer token, equivalent to the previous default behavior. - ExtensionFrameworkAuthentication (auth.extension_framework): short-lived installation or account-scoped token. It delegates the token call to the new InstallationsTokenService, refreshes proactively before the JWT exp (default 60s leeway) and reactively on 401. Pass account_id for an account-scoped token; use one provider instance per scope. Because providers subclass httpx.Auth, a single implementation works for both HTTPClient and AsyncHTTPClient. The clients hand their resolved configuration to the provider through Authentication.configure(); ExtensionFrameworkAuthentication uses it to build its dedicated token client instead of deriving it from the in-flight request. The http layer references Authentication only under TYPE_CHECKING, so it keeps no runtime dependency on a concrete auth module. The POST /public/v1/integration/installations/-/token call lives solely in InstallationsTokenService (client.integration.installations_token()), and http.jwt decodes the unverified exp claim to drive proactive refresh. Add unit coverage (tests/unit/auth, http/test_jwt, integration token service) and e2e coverage for the installation token and extension-framework auth flow (skipped unless MPT_API_TOKEN_EXTENSION is set). Breaking change: HTTPClient, AsyncHTTPClient, MPTClient.from_config and AsyncMPTClient.from_config now require an authentication provider instead of the previous api_token argument. The MPT_API_TOKEN environment variable is no longer read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.sample | 1 + README.md | 2 +- docs/architecture.md | 9 +- docs/e2e_tests.md | 13 +- docs/rql.md | 7 +- docs/usage.md | 74 ++++- mpt_api_client/__init__.py | 14 +- mpt_api_client/auth/__init__.py | 8 + mpt_api_client/auth/base.py | 40 +++ mpt_api_client/auth/extension_framework.py | 168 ++++++++++ mpt_api_client/auth/jwt.py | 56 ++++ mpt_api_client/http/async_client.py | 23 +- mpt_api_client/http/client.py | 23 +- mpt_api_client/mpt_client.py | 23 +- .../integration/installations_token.py | 62 ++++ .../resources/integration/integration.py | 12 + pyproject.toml | 4 + tests/e2e/auth/__init__.py | 0 tests/e2e/auth/conftest.py | 25 ++ .../auth/test_async_extension_framework.py | 9 + .../e2e/auth/test_sync_extension_framework.py | 9 + tests/e2e/conftest.py | 107 +++++-- .../installations_token/__init__.py | 0 .../installations_token/conftest.py | 23 ++ .../test_async_installations_token.py | 17 + .../test_sync_installations_token.py | 15 + tests/e2e/test_access.py | 27 +- tests/unit/auth/__init__.py | 0 tests/unit/auth/test_base.py | 13 + tests/unit/auth/test_extension_framework.py | 303 ++++++++++++++++++ tests/unit/auth/test_jwt.py | 48 +++ tests/unit/conftest.py | 5 +- tests/unit/http/test_async_client.py | 28 +- tests/unit/http/test_client.py | 28 +- .../integration/test_installations_token.py | 69 ++++ .../resources/integration/test_integration.py | 18 ++ tests/unit/test_mpt_client.py | 27 +- 37 files changed, 1169 insertions(+), 141 deletions(-) create mode 100644 mpt_api_client/auth/__init__.py create mode 100644 mpt_api_client/auth/base.py create mode 100644 mpt_api_client/auth/extension_framework.py create mode 100644 mpt_api_client/auth/jwt.py create mode 100644 mpt_api_client/resources/integration/installations_token.py create mode 100644 tests/e2e/auth/__init__.py create mode 100644 tests/e2e/auth/conftest.py create mode 100644 tests/e2e/auth/test_async_extension_framework.py create mode 100644 tests/e2e/auth/test_sync_extension_framework.py create mode 100644 tests/e2e/integration/installations_token/__init__.py create mode 100644 tests/e2e/integration/installations_token/conftest.py create mode 100644 tests/e2e/integration/installations_token/test_async_installations_token.py create mode 100644 tests/e2e/integration/installations_token/test_sync_installations_token.py create mode 100644 tests/unit/auth/__init__.py create mode 100644 tests/unit/auth/test_base.py create mode 100644 tests/unit/auth/test_extension_framework.py create mode 100644 tests/unit/auth/test_jwt.py create mode 100644 tests/unit/resources/integration/test_installations_token.py diff --git a/.env.sample b/.env.sample index eca413e8..72b1afe8 100644 --- a/.env.sample +++ b/.env.sample @@ -4,6 +4,7 @@ MPT_API_TOKEN= MPT_API_TOKEN_CLIENT= MPT_API_TOKEN_OPERATIONS= MPT_API_TOKEN_VENDOR= +MPT_API_TOKEN_EXTENSION= RP_API_KEY= RP_ENDPOINT=https://reportportal.example.com RP_LAUNCH=dev-env diff --git a/README.md b/README.md index d21f9667..2067ab27 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Start with these documents: ## Quick Start ```bash -cp .env.sample .env # configure MPT_API_BASE_URL and MPT_API_TOKEN +cp .env.sample .env # configure MPT_API_BASE_URL make build make test ``` diff --git a/docs/architecture.md b/docs/architecture.md index bcb924b0..d0ad6b61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ Each client holds an HTTP client instance and exposes domain-specific resource g properties: ```python -client = MPTClient.from_config(api_token="...", base_url="...") +client = MPTClient.from_config(authentication=BearerTokenAuthentication("..."), base_url="...") client.catalog # Catalog client.commerce # Commerce client.billing # Billing @@ -155,14 +155,15 @@ class ProductsService( `HTTPClient` and `AsyncHTTPClient` wrap `httpx.Client` / `httpx.AsyncClient` with: -- automatic Bearer token authentication +- pluggable authentication via an `Authentication` provider (`BearerTokenAuthentication`, + `ExtensionFrameworkAuthentication`) - base URL resolution - retry transport (configurable) - error transformation into `MPTHttpError` / `MPTAPIError` - multipart file upload support -Configuration is read from constructor arguments or environment variables -(`MPT_API_TOKEN`, `MPT_API_BASE_URL`). +The base URL is read from a constructor argument or the `MPT_API_BASE_URL` environment +variable; the authentication provider is always passed explicitly. ## Cross-Cutting Concerns diff --git a/docs/e2e_tests.md b/docs/e2e_tests.md index 91608a3d..abaf83c9 100644 --- a/docs/e2e_tests.md +++ b/docs/e2e_tests.md @@ -30,12 +30,13 @@ E2E suites use `pytest` markers and live API credentials, so they run outside th ## Environment Variables -| Variable | Description | -|----------------------------|----------------------| -| `MPT_API_BASE_URL` | MPT API base URL | -| `MPT_API_TOKEN_VENDOR` | Vendor API token | -| `MPT_API_TOKEN_CLIENT` | Client API token | -| `MPT_API_TOKEN_OPERATIONS` | Operations API token | +| Variable | Description | +|----------------------------|----------------------------------------------------| +| `MPT_API_BASE_URL` | MPT API base URL | +| `MPT_API_TOKEN_VENDOR` | Vendor API token | +| `MPT_API_TOKEN_CLIENT` | Client API token | +| `MPT_API_TOKEN_OPERATIONS` | Operations API token | +| `MPT_API_TOKEN_EXTENSION` | Extension API key (installation token / extension framework auth) | ### Optional ReportPortal Integration diff --git a/docs/rql.md b/docs/rql.md index 92881b56..0b1c1ebb 100644 --- a/docs/rql.md +++ b/docs/rql.md @@ -27,9 +27,12 @@ appends filters immutably, allowing expression composition without shared mutati You can build complex predicates by joining queries with `&` (AND), `|` (OR), and `~` (NOT): ```python -from mpt_api_client import MPTClient, RQLQuery +from mpt_api_client import MPTClient, BearerTokenAuthentication, RQLQuery -client = MPTClient() +client = MPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", +) products = client.catalog.products target_ids = RQLQuery("id").in_([ diff --git a/docs/usage.md b/docs/usage.md index ee6327d6..48edc5bf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -20,39 +20,67 @@ uv add mpt-api-client ## Configuration -The client reads configuration from constructor arguments or the environment. +The client requires a base URL and an authentication provider. Environment variables: | Variable | Required | Description | |--------------------|----------|------------------------------------| | `MPT_API_BASE_URL` | yes | SoftwareONE Marketplace API URL | -| `MPT_API_TOKEN` | yes | SoftwareONE Marketplace API token | + +The base URL can be read from the environment; the authentication provider is always passed +explicitly. Example `.env` snippet: ```env MPT_API_BASE_URL= -MPT_API_TOKEN= ``` +## Authentication + +Authentication is provided through an `Authentication` provider passed to the client. Two +implementations are available: + +- `BearerTokenAuthentication` — a single, long-lived token. +- `ExtensionFrameworkAuthentication` — a short-lived installation or account-scoped token + fetched from an extension secret via `POST /installations/-/token`. It refreshes + proactively once the token nears its JWT `exp` (default leeway 60s) and reactively on + `401`. Pass `account_id` to request a token scoped to a specific account + (`?account.id=`); use one provider instance per account scope. + ## Instantiate The Client -You can rely on environment variables: +With a long-lived bearer token: ```python -from mpt_api_client import MPTClient +from mpt_api_client import MPTClient, BearerTokenAuthentication -client = MPTClient() +client = MPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", +) ``` -Or pass configuration explicitly: +With the extension framework (short-lived installation tokens): ```python -from mpt_api_client import MPTClient +from mpt_api_client import MPTClient, ExtensionFrameworkAuthentication client = MPTClient.from_config( - api_token="token", + authentication=ExtensionFrameworkAuthentication(secret=""), + base_url="https://api.s1.show/public", +) +``` + +For an account-scoped token, pass `account_id`: + +```python +client = MPTClient.from_config( + authentication=ExtensionFrameworkAuthentication( + secret="", + account_id="", + ), base_url="https://api.s1.show/public", ) ``` @@ -64,9 +92,12 @@ client = MPTClient.from_config( Read a single resource: ```python -from mpt_api_client import MPTClient +from mpt_api_client import MPTClient, BearerTokenAuthentication -client = MPTClient() +client = MPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", +) product = client.catalog.products.get("PRD-123-456") print(product.name) @@ -75,9 +106,12 @@ print(product.name) Iterate through a collection: ```python -from mpt_api_client import MPTClient +from mpt_api_client import MPTClient, BearerTokenAuthentication -client = MPTClient() +client = MPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", +) for invoice in client.billing.invoices.iterate(): print(invoice.id) @@ -88,11 +122,14 @@ for invoice in client.billing.invoices.iterate(): ```python import asyncio -from mpt_api_client import AsyncMPTClient +from mpt_api_client import AsyncMPTClient, BearerTokenAuthentication async def main(): - client = AsyncMPTClient() + client = AsyncMPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", + ) product = await client.catalog.products.get("PRD-123-456") print(product.name) @@ -134,9 +171,12 @@ the source of truth for query composition. Typical example: ```python -from mpt_api_client import MPTClient, RQLQuery +from mpt_api_client import MPTClient, BearerTokenAuthentication, RQLQuery -client = MPTClient() +client = MPTClient.from_config( + authentication=BearerTokenAuthentication(""), + base_url="https://api.s1.show/public", +) target_ids = RQLQuery("id").in_(["PRD-123-456", "PRD-789-012"]) active = RQLQuery(status="active") diff --git a/mpt_api_client/__init__.py b/mpt_api_client/__init__.py index 5f3cf009..7b1a89dd 100644 --- a/mpt_api_client/__init__.py +++ b/mpt_api_client/__init__.py @@ -1,4 +1,16 @@ +from mpt_api_client.auth import ( + Authentication, + BearerTokenAuthentication, + ExtensionFrameworkAuthentication, +) from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient from mpt_api_client.rql import RQLQuery -__all__ = ["AsyncMPTClient", "MPTClient", "RQLQuery"] # noqa: WPS410 +__all__ = [ # noqa: WPS410 + "AsyncMPTClient", + "Authentication", + "BearerTokenAuthentication", + "ExtensionFrameworkAuthentication", + "MPTClient", + "RQLQuery", +] diff --git a/mpt_api_client/auth/__init__.py b/mpt_api_client/auth/__init__.py new file mode 100644 index 00000000..bbe25d04 --- /dev/null +++ b/mpt_api_client/auth/__init__.py @@ -0,0 +1,8 @@ +from mpt_api_client.auth.base import Authentication, BearerTokenAuthentication +from mpt_api_client.auth.extension_framework import ExtensionFrameworkAuthentication + +__all__ = [ # noqa: WPS410 + "Authentication", + "BearerTokenAuthentication", + "ExtensionFrameworkAuthentication", +] diff --git a/mpt_api_client/auth/base.py b/mpt_api_client/auth/base.py new file mode 100644 index 00000000..9abf2b5b --- /dev/null +++ b/mpt_api_client/auth/base.py @@ -0,0 +1,40 @@ +"""Generic authentication providers for the MPT API client. + +Providers are :class:`httpx.Auth` subclasses, so the same implementation is used by both +the sync and the async HTTP clients. +""" + +from collections.abc import Generator +from typing import override + +import httpx + + +class Authentication(httpx.Auth): + """Base class for MPT API authentication providers.""" + + def configure(self, *, base_url: str, timeout: float, retries: int) -> None: + """Receive the owning HTTP client's configuration. + + Called once by ``HTTPClient``/``AsyncHTTPClient`` at construction time. The base + implementation is a no-op; providers that need the client's configuration (such as + ``ExtensionFrameworkAuthentication``) override it. + + Args: + base_url: Resolved base URL of the owning client. + timeout: HTTP request timeout in seconds. + retries: Number of retries configured on the owning client. + """ + + +class BearerTokenAuthentication(Authentication): + """Authenticate every request with a single long-lived bearer token.""" + + def __init__(self, token: str) -> None: + self._token = token + + @override + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + """Attach the bearer token to the outgoing request.""" + request.headers["Authorization"] = f"Bearer {self._token}" + yield request diff --git a/mpt_api_client/auth/extension_framework.py b/mpt_api_client/auth/extension_framework.py new file mode 100644 index 00000000..5d52d484 --- /dev/null +++ b/mpt_api_client/auth/extension_framework.py @@ -0,0 +1,168 @@ +"""Extension framework authentication for the MPT integration API. + +This provider fetches its short-lived token through :class:`InstallationsTokenService`, the +single owner of the installations token endpoint, over a dedicated client authenticated with +the extension secret. +""" + +import datetime as dt +from collections.abc import AsyncGenerator, Generator +from typing import override + +import httpx + +from mpt_api_client.auth.base import Authentication, BearerTokenAuthentication +from mpt_api_client.auth.jwt import JWTClaimsError, JWTFormatError, decode_unverified_jwt_claims +from mpt_api_client.exceptions import MPTError +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from mpt_api_client.resources.integration.installations_token import ( + AsyncInstallationsTokenService, + InstallationsTokenService, +) + +DEFAULT_TOKEN_VALIDITY_LEEWAY_SECONDS = 60 + + +class ExtensionFrameworkAuthentication(Authentication): + """Authenticate with a short-lived installation or account-scoped token. + + The token is fetched through the installations token service using the extension secret + and cached. When ``account_id`` is provided, the token is scoped to that account. + Refresh happens proactively once the token is within ``min_remaining_validity_seconds`` + of its JWT ``exp`` claim, with a reactive refresh on ``401 Unauthorized`` as a fallback + for tokens revoked before they expire. When the fetched token carries no readable + ``exp`` claim, proactive refresh is skipped and only the reactive ``401`` path applies. + + The token call is delegated to :class:`InstallationsTokenService` (and its async + counterpart) over a dedicated client authenticated with the extension secret; that + client's base URL is supplied by the owning HTTP client through :meth:`configure`. Each + provider instance caches a single token for its own scope, so callers needing several + account scopes should construct one provider per scope. + """ + + def __init__( + self, + secret: str, + account_id: str | None = None, + min_remaining_validity_seconds: int = DEFAULT_TOKEN_VALIDITY_LEEWAY_SECONDS, + ) -> None: + """Initialize the provider. + + Args: + secret: Extension secret used to authenticate token requests. + account_id: When set, request a token scoped to this account. + min_remaining_validity_seconds: Proactive refresh leeway before the JWT ``exp``. + """ + self._secret = secret + self._account_id = account_id + self._min_remaining_validity_seconds = min_remaining_validity_seconds + self._token: str | None = None + self._expires_at: dt.datetime | None = None + self._base_url: str | None = None + self._timeout: float = 20.0 + self._retries: int = 5 + self._sync_service: InstallationsTokenService | None = None + self._async_service: AsyncInstallationsTokenService | None = None + + @override + def configure(self, *, base_url: str, timeout: float, retries: int) -> None: + """Store the owning client's configuration used to build the token client.""" + self._base_url = base_url + self._timeout = timeout + self._retries = retries + + @override + def sync_auth_flow( + self, request: httpx.Request + ) -> Generator[httpx.Request, httpx.Response, None]: + """Attach a cached token, refreshing it proactively and on 401.""" + if self._token is None or self._is_expired(): + self._refresh_sync() + request.headers["Authorization"] = f"Bearer {self._token}" + response = yield request + if response.status_code == httpx.codes.UNAUTHORIZED: + self._refresh_sync() + request.headers["Authorization"] = f"Bearer {self._token}" + yield request + + @override + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Attach a cached token, refreshing it proactively and on 401.""" + if self._token is None or self._is_expired(): + await self._refresh_async() + request.headers["Authorization"] = f"Bearer {self._token}" + response = yield request + if response.status_code == httpx.codes.UNAUTHORIZED: + await self._refresh_async() + request.headers["Authorization"] = f"Bearer {self._token}" + yield request + + def _refresh_sync(self) -> None: + """Fetch and cache a fresh token via the sync installations token service.""" + token = self._get_sync_service().token(self._account_id) + self._store(token.token) + + async def _refresh_async(self) -> None: + """Fetch and cache a fresh token via the async installations token service.""" + token = await self._get_async_service().token(self._account_id) + self._store(token.token) + + def _get_sync_service(self) -> InstallationsTokenService: + """Return the cached sync token service, building it on first use.""" + if self._sync_service is None: + token_client = HTTPClient( + authentication=BearerTokenAuthentication(self._secret), + base_url=self._require_base_url(), + timeout=self._timeout, + retries=self._retries, + ) + self._sync_service = InstallationsTokenService(http_client=token_client) + return self._sync_service + + def _get_async_service(self) -> AsyncInstallationsTokenService: + """Return the cached async token service, building it on first use.""" + if self._async_service is None: + token_client = AsyncHTTPClient( + authentication=BearerTokenAuthentication(self._secret), + base_url=self._require_base_url(), + timeout=self._timeout, + retries=self._retries, + ) + self._async_service = AsyncInstallationsTokenService(http_client=token_client) + return self._async_service + + def _require_base_url(self) -> str: + """Return the configured base URL, raising when the provider is unconfigured.""" + if self._base_url is None: + raise MPTError( + "ExtensionFrameworkAuthentication must be used with an MPT HTTPClient or " + "AsyncHTTPClient; the base URL was not configured.", + ) + return self._base_url + + def _store(self, token: str | None) -> None: + """Cache a freshly fetched token and its expiry.""" + if not token: + raise MPTError("Installations token endpoint returned an empty token.") + self._token = token + self._expires_at = self._read_expiry(token) + + def _read_expiry(self, token: str) -> dt.datetime | None: + """Read the ``exp`` claim from the token, ignoring tokens without one.""" + try: + claims = decode_unverified_jwt_claims(token) + except (JWTFormatError, JWTClaimsError): + return None + exp = claims.get("exp") + if not isinstance(exp, int): + return None + return dt.datetime.fromtimestamp(exp, tz=dt.UTC) + + def _is_expired(self) -> bool: + """Return whether the cached token is within the refresh leeway of expiry.""" + if self._expires_at is None: + return False + threshold = dt.datetime.now(dt.UTC).timestamp() + self._min_remaining_validity_seconds + return self._expires_at.timestamp() <= threshold diff --git a/mpt_api_client/auth/jwt.py b/mpt_api_client/auth/jwt.py new file mode 100644 index 00000000..2bd48f15 --- /dev/null +++ b/mpt_api_client/auth/jwt.py @@ -0,0 +1,56 @@ +"""Decode JWT claims without verifying the token signature. + +The platform issues the tokens; the client only reads claims such as ``exp`` to decide +when to refresh proactively. Signature verification is intentionally not performed. +""" + +import base64 +import binascii +import json +from typing import Any + + +class JWTFormatError(ValueError): + """Raised when a token does not contain a JWT claims payload.""" + + +class JWTClaimsError(ValueError): + """Raised when JWT claims cannot be decoded.""" + + def __init__(self, message: str = "Invalid token claims") -> None: + super().__init__(message) + + +def decode_unverified_jwt_claims(token: str) -> dict[str, Any]: + """Decode JWT claims without verifying the token signature.""" + token_parts = token.split(".") + if len(token_parts) != 3: + raise JWTFormatError("Token is not a JWT") + + payload = _add_urlsafe_padding(token_parts[1]) + claims = _load_claims(_decode_urlsafe_payload(payload)) + + if not isinstance(claims, dict): + raise JWTClaimsError + return claims + + +def _add_urlsafe_padding(payload: str) -> str: + """Add missing base64 URL-safe padding.""" + return payload + "=" * ((4 - len(payload) % 4) % 4) + + +def _decode_urlsafe_payload(payload: str) -> str: + """Decode a base64 URL-safe JWT payload.""" + try: + return base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as error: + raise JWTClaimsError from error + + +def _load_claims(payload: str) -> Any: + """Load JWT claims JSON.""" + try: + return json.loads(payload) + except json.JSONDecodeError as error: + raise JWTClaimsError from error diff --git a/mpt_api_client/http/async_client.py b/mpt_api_client/http/async_client.py index f03e4b9c..a7eca86e 100644 --- a/mpt_api_client/http/async_client.py +++ b/mpt_api_client/http/async_client.py @@ -1,7 +1,7 @@ import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import Any +from typing import TYPE_CHECKING, Any from httpx import AsyncClient, HTTPError, RequestError from httpx import Response as HTTPXResponse @@ -15,6 +15,9 @@ from mpt_api_client.http.request_response_utils import handle_response_http_error from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response +if TYPE_CHECKING: + from mpt_api_client.auth.base import Authentication + class AsyncHTTPClient: """Async HTTP client for interacting with SoftwareOne Marketplace Platform API.""" @@ -22,8 +25,8 @@ class AsyncHTTPClient: def __init__( self, *, + authentication: "Authentication", base_url: str | None = None, - api_token: str | None = None, timeout: float = 20.0, retries: int = 5, ): @@ -34,22 +37,12 @@ def __init__( ) transport = RetryTransport(retry=retry) - api_token = api_token or os.getenv("MPT_API_TOKEN") - if not api_token: - raise ValueError( - "API token is required. " - "Set it up as env variable MPT_API_TOKEN or pass it as `api_token` " - "argument to MPTClient." - ) - base_url = validate_base_url(base_url or os.getenv("MPT_API_BASE_URL")) - base_headers = { - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": f"Bearer {api_token}", - } + authentication.configure(base_url=base_url, timeout=timeout, retries=self._retries) self.httpx_client = AsyncClient( base_url=base_url, - headers=base_headers, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=authentication, timeout=timeout, transport=transport, follow_redirects=True, diff --git a/mpt_api_client/http/client.py b/mpt_api_client/http/client.py index 5862e096..5041f2b0 100644 --- a/mpt_api_client/http/client.py +++ b/mpt_api_client/http/client.py @@ -2,7 +2,7 @@ import os from collections.abc import Iterator from contextlib import contextmanager -from typing import Any +from typing import TYPE_CHECKING, Any from httpx import Client, HTTPError, RequestError from httpx import Response as HTTPXResponse @@ -16,6 +16,9 @@ from mpt_api_client.http.types import HeaderTypes, QueryParam, RequestFiles, Response from mpt_api_client.models import ResourceData +if TYPE_CHECKING: + from mpt_api_client.auth.base import Authentication + def json_to_file_payload(resource_data: ResourceData | None) -> bytes: """Convert resource data to file payload.""" @@ -32,8 +35,8 @@ class HTTPClient: def __init__( self, *, + authentication: "Authentication", base_url: str | None = None, - api_token: str | None = None, timeout: float = 20.0, retries: int = 5, ): @@ -44,22 +47,12 @@ def __init__( ) transport = RetryTransport(retry=retry) - api_token = api_token or os.getenv("MPT_API_TOKEN") - if not api_token: - raise ValueError( - "API token is required. " - "Set it up as env variable MPT_API_TOKEN or pass it as `api_token` " - "argument to MPTClient." - ) - base_url = validate_base_url(base_url or os.getenv("MPT_API_BASE_URL")) - base_headers = { - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": f"Bearer {api_token}", - } + authentication.configure(base_url=base_url, timeout=timeout, retries=self._retries) self.httpx_client = Client( base_url=base_url, - headers=base_headers, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=authentication, timeout=timeout, transport=transport, follow_redirects=True, diff --git a/mpt_api_client/mpt_client.py b/mpt_api_client/mpt_client.py index ac10c60b..f3909872 100644 --- a/mpt_api_client/mpt_client.py +++ b/mpt_api_client/mpt_client.py @@ -1,5 +1,6 @@ from typing import Self +from mpt_api_client.auth import Authentication from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.resources import ( Accounts, @@ -32,21 +33,21 @@ class AsyncMPTClient: def __init__( self, - http_client: AsyncHTTPClient | None = None, + http_client: AsyncHTTPClient, ): - self.http_client = http_client or AsyncHTTPClient() + self.http_client = http_client @classmethod def from_config( cls, - api_token: str, + authentication: Authentication, base_url: str, timeout: float = 60.0, ) -> Self: """Create MPT client from configuration. Args: - api_token: MPT API Token + authentication: Authentication provider (e.g. BearerTokenAuthentication). base_url: MPT Base URL timeout: HTTP request timeout in seconds. Defaults to 60.0. @@ -54,7 +55,9 @@ def from_config( MPT Client """ - return cls(AsyncHTTPClient(base_url=base_url, api_token=api_token, timeout=timeout)) + return cls( + AsyncHTTPClient(authentication=authentication, base_url=base_url, timeout=timeout) + ) @property def catalog(self) -> AsyncCatalog: @@ -117,21 +120,21 @@ class MPTClient: def __init__( self, - http_client: HTTPClient | None = None, + http_client: HTTPClient, ): - self.http_client = http_client or HTTPClient() + self.http_client = http_client @classmethod def from_config( cls, - api_token: str, + authentication: Authentication, base_url: str, timeout: float = 60.0, ) -> Self: """Create MPT client from configuration. Args: - api_token: MPT API Token + authentication: Authentication provider (e.g. BearerTokenAuthentication). base_url: MPT Base URL timeout: HTTP request timeout in seconds. Defaults to 60.0. @@ -139,7 +142,7 @@ def from_config( MPT Client """ - return cls(HTTPClient(base_url=base_url, api_token=api_token, timeout=timeout)) + return cls(HTTPClient(authentication=authentication, base_url=base_url, timeout=timeout)) @property def commerce(self) -> Commerce: diff --git a/mpt_api_client/resources/integration/installations_token.py b/mpt_api_client/resources/integration/installations_token.py new file mode 100644 index 00000000..6d464a7d --- /dev/null +++ b/mpt_api_client/resources/integration/installations_token.py @@ -0,0 +1,62 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.types import QueryParam +from mpt_api_client.models import Model + + +class InstallationsToken(Model): + """Integration installations token resource. + + Attributes: + token: The installation or account-scoped token. + """ + + token: str | None + + +class InstallationsTokenServiceConfig: + """Installations token service configuration.""" + + _endpoint = "/public/v1/integration/installations/-/token" + _model_class = InstallationsToken + + +class InstallationsTokenService( + Service[InstallationsToken], + InstallationsTokenServiceConfig, +): + """Sync service for the /public/v1/integration/installations/-/token endpoint.""" + + def token(self, account_id: str | None = None) -> InstallationsToken: + """Request an installation token, optionally scoped to an account. + + Args: + account_id: When provided, request a token scoped to this account + (sent as the ``account.id`` query parameter). + + Returns: + The token resource. + """ + query_params: QueryParam | None = {"account.id": account_id} if account_id else None + response = self.http_client.request("post", self.path, query_params=query_params) + return self._model_class.from_response(response) # type: ignore[return-value] + + +class AsyncInstallationsTokenService( + AsyncService[InstallationsToken], + InstallationsTokenServiceConfig, +): + """Async service for the /public/v1/integration/installations/-/token endpoint.""" + + async def token(self, account_id: str | None = None) -> InstallationsToken: + """Request an installation token, optionally scoped to an account. + + Args: + account_id: When provided, request a token scoped to this account + (sent as the ``account.id`` query parameter). + + Returns: + The token resource. + """ + query_params: QueryParam | None = {"account.id": account_id} if account_id else None + response = await self.http_client.request("post", self.path, query_params=query_params) + return self._model_class.from_response(response) # type: ignore[return-value] diff --git a/mpt_api_client/resources/integration/integration.py b/mpt_api_client/resources/integration/integration.py index 6a24bab5..24500ab8 100644 --- a/mpt_api_client/resources/integration/integration.py +++ b/mpt_api_client/resources/integration/integration.py @@ -11,6 +11,10 @@ AsyncInstallationsService, InstallationsService, ) +from mpt_api_client.resources.integration.installations_token import ( + AsyncInstallationsTokenService, + InstallationsTokenService, +) class Integration: @@ -34,6 +38,10 @@ def installations(self) -> InstallationsService: """Installations service.""" return InstallationsService(http_client=self.http_client) + def installations_token(self) -> InstallationsTokenService: + """Installations token service.""" + return InstallationsTokenService(http_client=self.http_client) + class AsyncIntegration: """Async Integration MPT API Module.""" @@ -55,3 +63,7 @@ def categories(self) -> AsyncCategoriesService: def installations(self) -> AsyncInstallationsService: """Installations service.""" return AsyncInstallationsService(http_client=self.http_client) + + def installations_token(self) -> AsyncInstallationsTokenService: + """Installations token service.""" + return AsyncInstallationsTokenService(http_client=self.http_client) diff --git a/pyproject.toml b/pyproject.toml index 2d648a80..d6be511e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ select = ["AAA", "E999", "WPS"] show-source = true statistics = false per-file-ignores = [ + "mpt_api_client/auth/extension_framework.py: WPS214", "mpt_api_client/models/model.py: WPS215 WPS110", "mpt_api_client/mpt_client.py: WPS214 WPS235", "mpt_api_client/resources/*: WPS215", @@ -151,8 +152,11 @@ per-file-ignores = [ "tests/e2e/integration/*.py: WPS453", "tests/e2e/integration/extensions/*.py: WPS453 WPS202", "tests/e2e/integration/installations/*.py: WPS453 WPS202", + "tests/e2e/integration/installations_token/*.py: WPS453 WPS202", + "tests/e2e/auth/*.py: WPS118 WPS453 WPS202", "tests/unit/resources/integration/*.py: WPS202 WPS210 WPS218 WPS453", "tests/unit/resources/integration/mixins/*.py: WPS453 WPS202", + "tests/unit/auth/test_extension_framework.py: AAA01 AAA05 WPS118 WPS202 WPS204 WPS210 WPS218 WPS221 WPS430 WPS432 WPS453", "tests/unit/resources/commerce/*.py: WPS202 WPS204", "tests/unit/resources/program/*.py: WPS202 WPS210 WPS218", "tests/unit/test_mpt_client.py: WPS235", diff --git a/tests/e2e/auth/__init__.py b/tests/e2e/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/auth/conftest.py b/tests/e2e/auth/conftest.py new file mode 100644 index 00000000..7911b1b3 --- /dev/null +++ b/tests/e2e/auth/conftest.py @@ -0,0 +1,25 @@ +import pytest + +from mpt_api_client import AsyncMPTClient, ExtensionFrameworkAuthentication, MPTClient + + +@pytest.fixture +def mpt_extension_framework(extension_secret, installation_account_id, base_url, api_timeout): + return MPTClient.from_config( + authentication=ExtensionFrameworkAuthentication( + secret=extension_secret, account_id=installation_account_id + ), + base_url=base_url, + timeout=api_timeout, + ) + + +@pytest.fixture +def async_mpt_extension_framework(extension_secret, installation_account_id, base_url, api_timeout): + return AsyncMPTClient.from_config( + authentication=ExtensionFrameworkAuthentication( + secret=extension_secret, account_id=installation_account_id + ), + base_url=base_url, + timeout=api_timeout, + ) diff --git a/tests/e2e/auth/test_async_extension_framework.py b/tests/e2e/auth/test_async_extension_framework.py new file mode 100644 index 00000000..912947de --- /dev/null +++ b/tests/e2e/auth/test_async_extension_framework.py @@ -0,0 +1,9 @@ +import pytest + +pytestmark = [pytest.mark.flaky] + + +async def test_extension_framework_authenticates_request(async_mpt_extension_framework, product_id): + result = await async_mpt_extension_framework.catalog.products.get(product_id) + + assert result.id == product_id diff --git a/tests/e2e/auth/test_sync_extension_framework.py b/tests/e2e/auth/test_sync_extension_framework.py new file mode 100644 index 00000000..64be655a --- /dev/null +++ b/tests/e2e/auth/test_sync_extension_framework.py @@ -0,0 +1,9 @@ +import pytest + +pytestmark = [pytest.mark.flaky] + + +def test_extension_framework_authenticates_request(mpt_extension_framework, product_id): + result = mpt_extension_framework.catalog.products.get(product_id) + + assert result.id == product_id diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index fb515731..0306366c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -7,7 +7,8 @@ import pytest from reportportal_client import RPLogger -from mpt_api_client import AsyncMPTClient, MPTClient +from mpt_api_client import AsyncMPTClient, BearerTokenAuthentication, MPTClient +from mpt_api_client.exceptions import MPTAPIError @pytest.fixture(scope="session") @@ -15,51 +16,119 @@ def base_url(): return os.getenv("MPT_API_BASE_URL") +@pytest.fixture(scope="session") +def vendor_token(): + token = os.getenv("MPT_API_TOKEN_VENDOR") + if not token: + raise RuntimeError("Required environment variable 'MPT_API_TOKEN_VENDOR' is not set") + return token + + +@pytest.fixture(scope="session") +def operations_token(): + token = os.getenv("MPT_API_TOKEN_OPERATIONS") + if not token: + raise RuntimeError("Required environment variable 'MPT_API_TOKEN_OPERATIONS' is not set") + return token + + +@pytest.fixture(scope="session") +def client_token(): + token = os.getenv("MPT_API_TOKEN_CLIENT") + if not token: + raise RuntimeError("Required environment variable 'MPT_API_TOKEN_CLIENT' is not set") + return token + + +@pytest.fixture +def extension_secret(): + secret = os.getenv("MPT_API_TOKEN_EXTENSION") + if not secret: + pytest.skip("MPT_API_TOKEN_EXTENSION not set") + return secret + + +@pytest.fixture +def installation_account_id(mpt_vendor, e2e_config): + installations_service = mpt_vendor.integration.installations + installation = installations_service.create({ + "extension": {"id": e2e_config["integration.extension.id"]}, + "modules": [ + {"id": "MOD-0478"}, + {"id": "MOD-1239"}, + {"id": "MOD-1756"}, + {"id": "MOD-4525"}, + {"id": "MOD-8352"}, + {"id": "MOD-8743"}, + {"id": "MOD-9042"}, + ], + }) + + yield installation.account.id + + try: + installations_service.delete(installation.id) + except MPTAPIError as error: + print(f"TEARDOWN - Unable to delete installation {installation.id}: {error.title}") # noqa: WPS421 + + @pytest.fixture(scope="session") def api_timeout(): return float(os.getenv("MPT_API_TIMEOUT", "60.0")) @pytest.fixture -def mpt_vendor(base_url, api_timeout): +def mpt_vendor(base_url, api_timeout, vendor_token): return MPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_VENDOR"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(vendor_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture -def async_mpt_vendor(base_url, api_timeout): +def async_mpt_vendor(base_url, api_timeout, vendor_token): return AsyncMPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_VENDOR"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(vendor_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture -def mpt_ops(base_url, api_timeout): +def mpt_ops(base_url, api_timeout, operations_token): return MPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_OPERATIONS"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(operations_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture -def async_mpt_ops(base_url, api_timeout): +def async_mpt_ops(base_url, api_timeout, operations_token): return AsyncMPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_OPERATIONS"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(operations_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture -def mpt_client(base_url, api_timeout): +def mpt_client(base_url, api_timeout, client_token): return MPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_CLIENT"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(client_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture -def async_mpt_client(base_url, api_timeout): +def async_mpt_client(base_url, api_timeout, client_token): return AsyncMPTClient.from_config( - api_token=os.getenv("MPT_API_TOKEN_CLIENT"), base_url=base_url, timeout=api_timeout - ) # type: ignore + authentication=BearerTokenAuthentication(client_token), + base_url=base_url, + timeout=api_timeout, + ) @pytest.fixture(scope="module") diff --git a/tests/e2e/integration/installations_token/__init__.py b/tests/e2e/integration/installations_token/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/integration/installations_token/conftest.py b/tests/e2e/integration/installations_token/conftest.py new file mode 100644 index 00000000..48ef0410 --- /dev/null +++ b/tests/e2e/integration/installations_token/conftest.py @@ -0,0 +1,23 @@ +import pytest + +from mpt_api_client import AsyncMPTClient, BearerTokenAuthentication, MPTClient + + +@pytest.fixture +def installations_token_service(extension_secret, base_url, api_timeout): + client = MPTClient.from_config( + authentication=BearerTokenAuthentication(extension_secret), + base_url=base_url, + timeout=api_timeout, + ) + return client.integration.installations_token() + + +@pytest.fixture +def async_installations_token_service(extension_secret, base_url, api_timeout): + client = AsyncMPTClient.from_config( + authentication=BearerTokenAuthentication(extension_secret), + base_url=base_url, + timeout=api_timeout, + ) + return client.integration.installations_token() diff --git a/tests/e2e/integration/installations_token/test_async_installations_token.py b/tests/e2e/integration/installations_token/test_async_installations_token.py new file mode 100644 index 00000000..6e6dc235 --- /dev/null +++ b/tests/e2e/integration/installations_token/test_async_installations_token.py @@ -0,0 +1,17 @@ +import pytest + +pytestmark = [pytest.mark.flaky] + + +async def test_installations_token_account_scoped( + async_installations_token_service, installation_account_id +): + result = await async_installations_token_service.token(installation_account_id) + + assert result.token + + +async def test_installations_token(async_installations_token_service): + result = await async_installations_token_service.token() + + assert result.token diff --git a/tests/e2e/integration/installations_token/test_sync_installations_token.py b/tests/e2e/integration/installations_token/test_sync_installations_token.py new file mode 100644 index 00000000..4e58df66 --- /dev/null +++ b/tests/e2e/integration/installations_token/test_sync_installations_token.py @@ -0,0 +1,15 @@ +import pytest + +pytestmark = [pytest.mark.flaky] + + +def test_installations_token_account_scoped(installations_token_service, installation_account_id): + result = installations_token_service.token(installation_account_id) + + assert result.token + + +def test_installations_token(installations_token_service): + result = installations_token_service.token() + + assert result.token diff --git a/tests/e2e/test_access.py b/tests/e2e/test_access.py index d9041d22..ca303346 100644 --- a/tests/e2e/test_access.py +++ b/tests/e2e/test_access.py @@ -1,12 +1,19 @@ import pytest -from mpt_api_client import MPTClient +from mpt_api_client import ( + BearerTokenAuthentication, + ExtensionFrameworkAuthentication, + MPTClient, +) from mpt_api_client.exceptions import MPTHttpError @pytest.mark.flaky def test_unauthorised(base_url): - client = MPTClient.from_config(api_token="TKN-invalid", base_url=base_url) # noqa: S106 + client = MPTClient.from_config( + authentication=BearerTokenAuthentication("TKN-invalid"), + base_url=base_url, + ) with pytest.raises(MPTHttpError, match=r"401 Authentication Failed"): client.catalog.products.fetch_page() @@ -17,3 +24,19 @@ def test_access(mpt_vendor, product_id): result = mpt_vendor.catalog.products.get(product_id) assert result.id == product_id + + +@pytest.mark.flaky +def test_extension_framework_access( + extension_secret, installation_account_id, base_url, product_id +): + client = MPTClient.from_config( + authentication=ExtensionFrameworkAuthentication( + secret=extension_secret, account_id=installation_account_id + ), + base_url=base_url, + ) + + result = client.catalog.products.get(product_id) + + assert result.id == product_id diff --git a/tests/unit/auth/__init__.py b/tests/unit/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/auth/test_base.py b/tests/unit/auth/test_base.py new file mode 100644 index 00000000..63a487ee --- /dev/null +++ b/tests/unit/auth/test_base.py @@ -0,0 +1,13 @@ +import httpx + +from mpt_api_client.auth import BearerTokenAuthentication +from tests.unit.conftest import API_URL + + +def test_bearer_token_sets_authorization_header(): + authentication = BearerTokenAuthentication("my-token") + request = httpx.Request("GET", f"{API_URL}/") + + sent = next(authentication.auth_flow(request)) # act + + assert sent.headers["Authorization"] == "Bearer my-token" diff --git a/tests/unit/auth/test_extension_framework.py b/tests/unit/auth/test_extension_framework.py new file mode 100644 index 00000000..898e5723 --- /dev/null +++ b/tests/unit/auth/test_extension_framework.py @@ -0,0 +1,303 @@ +import base64 +import datetime as dt +import json + +import httpx +import pytest +import respx + +from mpt_api_client.auth import ExtensionFrameworkAuthentication +from mpt_api_client.exceptions import MPTAPIError, MPTError +from mpt_api_client.http import AsyncHTTPClient, HTTPClient +from tests.unit.conftest import API_URL + +SECRET = "extension-secret" +TOKEN_URL = f"{API_URL}/public/v1/integration/installations/-/token" +ORDERS_URL = f"{API_URL}/orders" + + +def _jwt_with_exp(expires_at: dt.datetime, subject: str = "token") -> str: + def encode(payload: object) -> str: + raw = json.dumps(payload).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + claims = {"exp": int(expires_at.timestamp()), "sub": subject} + return f"{encode({'alg': 'none'})}.{encode(claims)}.signature" + + +@respx.mock +def test_extension_framework_fetches_and_applies_token(): + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": "installation-token"}) + ) + target_route = respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + client.request("GET", "/orders") # act + + token_request = token_route.calls.last.request + target_request = target_route.calls.last.request + assert token_request.headers["Authorization"] == f"Bearer {SECRET}" + assert target_request.headers["Authorization"] == "Bearer installation-token" + + +@respx.mock +def test_extension_framework_caches_token_across_requests(): + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": "installation-token"}) + ) + respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + client.request("GET", "/orders") # first call populates the cache + + client.request("GET", "/orders") # act + + assert token_route.call_count == 1 + + +@respx.mock +def test_extension_framework_refreshes_expired_token(): + token_route = respx.post(TOKEN_URL).mock( + side_effect=[ + httpx.Response(200, json={"token": "stale-token"}), + httpx.Response(200, json={"token": "fresh-token"}), + ] + ) + target_route = respx.get(ORDERS_URL).mock( + side_effect=[ + httpx.Response(401, json={"error": "expired"}), + httpx.Response(200, json={"data": []}), + ] + ) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + response = client.request("GET", "/orders") # act + + target_request = target_route.calls.last.request + assert response.status_code == httpx.codes.OK + assert token_route.call_count == 2 + assert target_request.headers["Authorization"] == "Bearer fresh-token" + + +@respx.mock +def test_extension_framework_retries_non_idempotent_request_on_unauthorized(): + token_route = respx.post(TOKEN_URL).mock( + side_effect=[ + httpx.Response(200, json={"token": "stale-token"}), + httpx.Response(200, json={"token": "fresh-token"}), + ] + ) + target_route = respx.post(ORDERS_URL).mock( + side_effect=[ + httpx.Response(401, json={"error": "expired"}), + httpx.Response(201, json={"id": "ORD-1"}), + ] + ) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + response = client.request("POST", "/orders", json={"x": 1}) # act + + target_request = target_route.calls.last.request + assert response.status_code == httpx.codes.CREATED + assert token_route.call_count == 2 + assert target_route.call_count == 2 + assert target_request.headers["Authorization"] == "Bearer fresh-token" + + +@respx.mock +def test_extension_framework_surfaces_repeated_unauthorized(): + respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json={"token": "any-token"})) + target_route = respx.get(ORDERS_URL).mock( + return_value=httpx.Response(401, json={"error": "nope"}) + ) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + with pytest.raises(MPTAPIError) as exc_info: # act + client.request("GET", "/orders") + + assert exc_info.value.status_code == httpx.codes.UNAUTHORIZED + assert target_route.call_count == 2 # original + exactly one retry, then surfaced + + +@respx.mock +async def test_extension_framework_works_with_async_client(): + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": "installation-token"}) + ) + target_route = respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = AsyncHTTPClient( + base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET) + ) + + await client.request("GET", "/orders") # act + + target_request = target_route.calls.last.request + assert token_route.called + assert target_request.headers["Authorization"] == "Bearer installation-token" + + +@respx.mock +async def test_extension_framework_caches_token_across_requests_async(): + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": "installation-token"}) + ) + respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = AsyncHTTPClient( + base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET) + ) + + await client.request("GET", "/orders") # first call populates the cache + + await client.request("GET", "/orders") # act + + assert token_route.call_count == 1 + + +@respx.mock +async def test_extension_framework_refreshes_expired_token_async(): + token_route = respx.post(TOKEN_URL).mock( + side_effect=[ + httpx.Response(200, json={"token": "stale-token"}), + httpx.Response(200, json={"token": "fresh-token"}), + ] + ) + target_route = respx.get(ORDERS_URL).mock( + side_effect=[ + httpx.Response(401, json={"error": "expired"}), + httpx.Response(200, json={"data": []}), + ] + ) + client = AsyncHTTPClient( + base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET) + ) + + response = await client.request("GET", "/orders") # act + + target_request = target_route.calls.last.request + assert response.status_code == httpx.codes.OK + assert token_route.call_count == 2 + assert target_request.headers["Authorization"] == "Bearer fresh-token" + + +@respx.mock +async def test_extension_framework_retries_non_idempotent_request_on_unauthorized_async(): + token_route = respx.post(TOKEN_URL).mock( + side_effect=[ + httpx.Response(200, json={"token": "stale-token"}), + httpx.Response(200, json={"token": "fresh-token"}), + ] + ) + target_route = respx.post(ORDERS_URL).mock( + side_effect=[ + httpx.Response(401, json={"error": "expired"}), + httpx.Response(201, json={"id": "ORD-1"}), + ] + ) + client = AsyncHTTPClient( + base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET) + ) + + response = await client.request("POST", "/orders", json={"x": 1}) # act + + target_request = target_route.calls.last.request + assert response.status_code == httpx.codes.CREATED + assert token_route.call_count == 2 + assert target_route.call_count == 2 + assert target_request.headers["Authorization"] == "Bearer fresh-token" + + +@respx.mock +async def test_extension_framework_surfaces_repeated_unauthorized_async(): + respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json={"token": "any-token"})) + target_route = respx.get(ORDERS_URL).mock( + return_value=httpx.Response(401, json={"error": "nope"}) + ) + client = AsyncHTTPClient( + base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET) + ) + + with pytest.raises(MPTAPIError) as exc_info: # act + await client.request("GET", "/orders") + + assert exc_info.value.status_code == httpx.codes.UNAUTHORIZED + assert target_route.call_count == 2 # original + exactly one retry, then surfaced + + +@respx.mock +def test_extension_framework_token_error(): + respx.post(TOKEN_URL).mock(return_value=httpx.Response(403, json={"error": "forbidden"})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + with pytest.raises(MPTError): # act + client.request("GET", "/orders") + + +def test_extension_framework_requires_configuration(): + provider = ExtensionFrameworkAuthentication(SECRET) + auth_flow = provider.sync_auth_flow(httpx.Request("GET", ORDERS_URL)) + + with pytest.raises(MPTError): # act + next(auth_flow) + + +@respx.mock +def test_extension_framework_rejects_empty_token(): + respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json={"token": None})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + with pytest.raises(MPTError): # act + client.request("GET", "/orders") + + +@respx.mock +def test_extension_framework_account_scoped_token_request(): + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": "account-token"}) + ) + respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = HTTPClient( + base_url=API_URL, + authentication=ExtensionFrameworkAuthentication(SECRET, account_id="ACC-123"), + ) + + client.request("GET", "/orders") # act + + token_request = token_route.calls.last.request + assert token_request.url.params["account.id"] == "ACC-123" + + +@respx.mock +def test_extension_framework_refreshes_proactively_before_expiry(): + near_expiry = dt.datetime.now(dt.UTC) + dt.timedelta(seconds=10) + far_expiry = dt.datetime.now(dt.UTC) + dt.timedelta(hours=1) + token_route = respx.post(TOKEN_URL).mock( + side_effect=[ + httpx.Response(200, json={"token": _jwt_with_exp(near_expiry, "stale")}), + httpx.Response(200, json={"token": _jwt_with_exp(far_expiry, "fresh")}), + ] + ) + respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + client.request("GET", "/orders") # first call caches a near-expiry token + + client.request("GET", "/orders") # act: token within leeway -> proactive refresh + + assert token_route.call_count == 2 + + +@respx.mock +def test_extension_framework_reuses_unexpired_token(): + far_expiry = dt.datetime.now(dt.UTC) + dt.timedelta(hours=1) + token_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(200, json={"token": _jwt_with_exp(far_expiry)}) + ) + respx.get(ORDERS_URL).mock(return_value=httpx.Response(200, json={"data": []})) + client = HTTPClient(base_url=API_URL, authentication=ExtensionFrameworkAuthentication(SECRET)) + + client.request("GET", "/orders") # caches a long-lived token + + client.request("GET", "/orders") # act + + assert token_route.call_count == 1 diff --git a/tests/unit/auth/test_jwt.py b/tests/unit/auth/test_jwt.py new file mode 100644 index 00000000..199da8d1 --- /dev/null +++ b/tests/unit/auth/test_jwt.py @@ -0,0 +1,48 @@ +import base64 +import json + +import pytest + +from mpt_api_client.auth.jwt import ( + JWTClaimsError, + JWTFormatError, + decode_unverified_jwt_claims, +) + + +def _encode_segment(payload: object) -> str: + raw = json.dumps(payload).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + +def _build_jwt(claims: dict) -> str: + return f"{_encode_segment({'alg': 'none'})}.{_encode_segment(claims)}.signature" + + +def test_decode_valid_jwt_returns_claims(): + token = _build_jwt({"exp": 1234567890, "sub": "user"}) + + claims = decode_unverified_jwt_claims(token) # act + + assert claims == {"exp": 1234567890, "sub": "user"} + + +def test_decode_rejects_token_without_three_parts(): + with pytest.raises(JWTFormatError): # act + decode_unverified_jwt_claims("not.a-jwt") + + +def test_decode_rejects_invalid_base64_payload(): + token = "header.!!!not-base64!!!.signature" + + with pytest.raises(JWTClaimsError): # act + decode_unverified_jwt_claims(token) + + +def test_decode_rejects_non_dict_claims(): + header_segment = _encode_segment({"alg": "none"}) + payload_segment = _encode_segment([1, 2, 3]) + token = f"{header_segment}.{payload_segment}.signature" + + with pytest.raises(JWTClaimsError): # act + decode_unverified_jwt_claims(token) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index df76d043..108f44bc 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,5 +1,6 @@ import pytest +from mpt_api_client.auth import BearerTokenAuthentication from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.models import Model @@ -13,12 +14,12 @@ class DummyModel(Model): @pytest.fixture def http_client(): - return HTTPClient(base_url=API_URL, api_token=API_TOKEN) + return HTTPClient(base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN)) @pytest.fixture def async_http_client(): - return AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) + return AsyncHTTPClient(base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN)) @pytest.fixture diff --git a/tests/unit/http/test_async_client.py b/tests/unit/http/test_async_client.py index 1211eeb6..f8ada772 100644 --- a/tests/unit/http/test_async_client.py +++ b/tests/unit/http/test_async_client.py @@ -5,6 +5,7 @@ import respx from httpx import ConnectTimeout, Request, Response, codes +from mpt_api_client.auth import BearerTokenAuthentication from mpt_api_client.exceptions import MPTAPIError, MPTError, MPTMaxRetryError from mpt_api_client.http.async_client import AsyncHTTPClient from mpt_api_client.http.query_options import QueryOptions @@ -23,47 +24,36 @@ def mock_response(mock_request): def test_async_http_initialization(mocker): mock_async_client = mocker.patch("mpt_api_client.http.async_client.AsyncClient") + authentication = BearerTokenAuthentication(API_TOKEN) - AsyncHTTPClient(base_url=API_URL, api_token=API_TOKEN) # act + AsyncHTTPClient(base_url=API_URL, authentication=authentication) # act mock_async_client.assert_called_once_with( base_url=API_URL, follow_redirects=True, - headers={ - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": "Bearer test-token", - }, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=authentication, timeout=20.0, transport=mocker.ANY, ) -def test_async_env_initialization(monkeypatch, mocker): +def test_async_env_base_url_initialization(monkeypatch, mocker): monkeypatch.setenv("MPT_API_BASE_URL", API_URL) - monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) mock_async_client = mocker.patch("mpt_api_client.http.async_client.AsyncClient") - AsyncHTTPClient() # act + AsyncHTTPClient(authentication=BearerTokenAuthentication(API_TOKEN)) # act mock_async_client.assert_called_once_with( base_url=API_URL, follow_redirects=True, - headers={ - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": f"Bearer {API_TOKEN}", - }, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=mocker.ANY, timeout=20.0, transport=mocker.ANY, ) -def test_async_http_without_token(monkeypatch): - monkeypatch.delenv("MPT_API_TOKEN", raising=False) - - with pytest.raises(ValueError): - AsyncHTTPClient(base_url=API_URL) - - @respx.mock async def test_async_http_call_success(async_http_client, mock_response): success_route = respx.get(f"{API_URL}/").mock(return_value=mock_response) diff --git a/tests/unit/http/test_client.py b/tests/unit/http/test_client.py index 2d14ed86..6f7c878f 100644 --- a/tests/unit/http/test_client.py +++ b/tests/unit/http/test_client.py @@ -5,6 +5,7 @@ import respx from httpx import ConnectTimeout, Response, codes +from mpt_api_client.auth import BearerTokenAuthentication from mpt_api_client.exceptions import MPTAPIError, MPTMaxRetryError from mpt_api_client.http.client import HTTPClient from mpt_api_client.http.query_options import QueryOptions @@ -13,47 +14,36 @@ def test_http_initialization(mocker): mock_client = mocker.patch("mpt_api_client.http.client.Client") + authentication = BearerTokenAuthentication(API_TOKEN) - HTTPClient(base_url=API_URL, api_token=API_TOKEN) # act + HTTPClient(base_url=API_URL, authentication=authentication) # act mock_client.assert_called_once_with( base_url=API_URL, follow_redirects=True, - headers={ - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": "Bearer test-token", - }, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=authentication, timeout=20.0, transport=mocker.ANY, ) -def test_env_initialization(monkeypatch, mocker): +def test_env_base_url_initialization(monkeypatch, mocker): monkeypatch.setenv("MPT_API_BASE_URL", API_URL) - monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) mock_client = mocker.patch("mpt_api_client.http.client.Client") - HTTPClient() # act + HTTPClient(authentication=BearerTokenAuthentication(API_TOKEN)) # act mock_client.assert_called_once_with( base_url=API_URL, follow_redirects=True, - headers={ - "User-Agent": "swo-marketplace-client/1.0", - "Authorization": f"Bearer {API_TOKEN}", - }, + headers={"User-Agent": "swo-marketplace-client/1.0"}, + auth=mocker.ANY, timeout=20.0, transport=mocker.ANY, ) -def test_http_without_token(monkeypatch): - monkeypatch.delenv("MPT_API_TOKEN", raising=False) - - with pytest.raises(ValueError): - HTTPClient(base_url=API_URL) - - @respx.mock def test_http_call_success(http_client): success_route = respx.get(f"{API_URL}/").mock( diff --git a/tests/unit/resources/integration/test_installations_token.py b/tests/unit/resources/integration/test_installations_token.py new file mode 100644 index 00000000..5620ea80 --- /dev/null +++ b/tests/unit/resources/integration/test_installations_token.py @@ -0,0 +1,69 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.integration.installations_token import ( + AsyncInstallationsTokenService, + InstallationsToken, + InstallationsTokenService, +) +from tests.unit.conftest import API_URL + +TOKEN_URL = f"{API_URL}/public/v1/integration/installations/-/token" + + +@pytest.fixture +def installations_token_service(http_client): + return InstallationsTokenService(http_client=http_client) + + +@pytest.fixture +def async_installations_token_service(async_http_client): + return AsyncInstallationsTokenService(http_client=async_http_client) + + +def test_token_endpoint(installations_token_service): + endpoint = installations_token_service.path # act + + assert endpoint == "/public/v1/integration/installations/-/token" + + +def test_token_posts_and_returns_model(installations_token_service): + with respx.mock: + mock_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(httpx.codes.CREATED, json={"token": "installation-token"}) + ) + + result = installations_token_service.token() + + request = mock_route.calls[0].request + assert isinstance(result, InstallationsToken) + assert result.token == "installation-token" + assert request.method == "POST" + assert "account.id" not in request.url.params + + +def test_token_account_scoped(installations_token_service): + with respx.mock: + mock_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(httpx.codes.CREATED, json={"token": "account-token"}) + ) + + result = installations_token_service.token("ACC-123") + + request = mock_route.calls[0].request + assert result.token == "account-token" + assert request.url.params["account.id"] == "ACC-123" + + +async def test_async_token_account_scoped(async_installations_token_service): + with respx.mock: + mock_route = respx.post(TOKEN_URL).mock( + return_value=httpx.Response(httpx.codes.CREATED, json={"token": "account-token"}) + ) + + result = await async_installations_token_service.token("ACC-123") + + request = mock_route.calls[0].request + assert result.token == "account-token" + assert request.url.params["account.id"] == "ACC-123" diff --git a/tests/unit/resources/integration/test_integration.py b/tests/unit/resources/integration/test_integration.py index 9b83ea03..a60c70a5 100644 --- a/tests/unit/resources/integration/test_integration.py +++ b/tests/unit/resources/integration/test_integration.py @@ -12,6 +12,10 @@ AsyncInstallationsService, InstallationsService, ) +from mpt_api_client.resources.integration.installations_token import ( + AsyncInstallationsTokenService, + InstallationsTokenService, +) from mpt_api_client.resources.integration.integration import ( AsyncIntegration, Integration, @@ -70,3 +74,17 @@ def test_async_integration_properties(async_integration, property_name, expected assert isinstance(result, expected_service_class) assert result.http_client is async_integration.http_client + + +def test_integration_installations_token(integration): + result = integration.installations_token() + + assert isinstance(result, InstallationsTokenService) + assert result.http_client is integration.http_client + + +def test_async_integration_installations_token(async_integration): + result = async_integration.installations_token() + + assert isinstance(result, AsyncInstallationsTokenService) + assert result.http_client is async_integration.http_client diff --git a/tests/unit/test_mpt_client.py b/tests/unit/test_mpt_client.py index 26812144..0ad1b5b4 100644 --- a/tests/unit/test_mpt_client.py +++ b/tests/unit/test_mpt_client.py @@ -1,5 +1,6 @@ import pytest +from mpt_api_client.auth import BearerTokenAuthentication from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient from mpt_api_client.resources import ( @@ -30,7 +31,9 @@ def get_mpt_client(): - return MPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) + return MPTClient.from_config( + base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN) + ) @pytest.mark.parametrize( @@ -50,7 +53,9 @@ def get_mpt_client(): ], ) def test_mpt_client(resource_name: str, expected_type: type) -> None: - mpt = MPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) + mpt = MPTClient.from_config( + base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN) + ) result = getattr(mpt, resource_name) @@ -58,11 +63,12 @@ def test_mpt_client(resource_name: str, expected_type: type) -> None: assert isinstance(result, expected_type) -def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: +def test_mpt_client_from_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MPT_API_BASE_URL", API_URL) - monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) - result = MPTClient() + result = MPTClient.from_config( + base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN) + ) assert isinstance(result, MPTClient) assert isinstance(result.http_client, HTTPClient) @@ -85,7 +91,9 @@ def test_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: ], ) def test_async_mpt_client(resource_name: str, expected_type: type) -> None: - mpt = AsyncMPTClient.from_config(base_url=API_URL, api_token=API_TOKEN) + mpt = AsyncMPTClient.from_config( + base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN) + ) result = getattr(mpt, resource_name) @@ -93,11 +101,12 @@ def test_async_mpt_client(resource_name: str, expected_type: type) -> None: assert isinstance(result, expected_type) -def test_async_mpt_client_env(monkeypatch: pytest.MonkeyPatch) -> None: +def test_async_mpt_client_from_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MPT_API_BASE_URL", API_URL) - monkeypatch.setenv("MPT_API_TOKEN", API_TOKEN) - result = AsyncMPTClient() + result = AsyncMPTClient.from_config( + base_url=API_URL, authentication=BearerTokenAuthentication(API_TOKEN) + ) assert isinstance(result, AsyncMPTClient) assert isinstance(result.http_client, AsyncHTTPClient)