From 99839cc6d0f97e063ca159b0289c632bb7098067 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:02:48 +0300 Subject: [PATCH 01/17] async v0 --- .gitleaks.toml | 16 + descope/__init__.py | 2 + descope/_auth_base.py | 20 + descope/_client_base.py | 243 +++++++ descope/_http_base.py | 12 + descope/async_descope_client.py | 220 ++++++ descope/async_http_client.py | 176 +++++ descope/authmethod/_totp_base.py | 60 ++ descope/authmethod/async_totp.py | 57 ++ descope/authmethod/totp.py | 57 +- descope/descope_client.py | 336 +-------- pyproject.toml | 7 +- tests/conftest.py | 185 +++++ tests/test_async_http_client.py | 440 ++++++++++++ tests/test_descope_client_parity.py | 1008 +++++++++++++++++++++++++++ tests/test_totp_parity.py | 219 ++++++ uv.lock | 71 +- 17 files changed, 2755 insertions(+), 374 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 descope/_client_base.py create mode 100644 descope/async_descope_client.py create mode 100644 descope/async_http_client.py create mode 100644 descope/authmethod/_totp_base.py create mode 100644 descope/authmethod/async_totp.py create mode 100644 tests/conftest.py create mode 100644 tests/test_async_http_client.py create mode 100644 tests/test_descope_client_parity.py create mode 100644 tests/test_totp_parity.py diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..61551b1f7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,16 @@ +title = "python-sdk gitleaks config" + +[allowlist] +description = "False positives: test fixture JWTs, coverage artifacts, README examples" +regexes = [ + # Fake JWTs used as test fixtures (kid: 2Bt5WLccLUey1Dp7utptZb3Fx9K) + '''eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpX''', + # Fake JWTs used as test fixtures (kid: P2CtzUhdqpIF2ys9gg7ms06UvtC4) + '''eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJK''', + # Example password in README docs + '''qYlvi65KaX''', +] +paths = [ + # Coverage report artifacts + '''htmlcov/''', +] diff --git a/descope/__init__.py b/descope/__init__.py index d11f1ddbb..9b64e921d 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,3 +1,4 @@ +from descope.async_descope_client import AsyncDescopeClient from descope.common import ( COOKIE_DATA_NAME, REFRESH_SESSION_COOKIE_NAME, @@ -64,6 +65,7 @@ "DeliveryMethod", "LoginOptions", "SignUpOptions", + "AsyncDescopeClient", "DescopeClient", "API_RATE_LIMIT_RETRY_AFTER_HEADER", "ERROR_TYPE_API_RATE_LIMIT", diff --git a/descope/_auth_base.py b/descope/_auth_base.py index debe09da8..965bd3560 100644 --- a/descope/_auth_base.py +++ b/descope/_auth_base.py @@ -1,6 +1,13 @@ # This is not part of the public API but a code helper +from __future__ import annotations + +from typing import TYPE_CHECKING + from descope.auth import Auth +if TYPE_CHECKING: + from descope.async_http_client import AsyncHTTPClient + class AuthBase: """Base class for classes having auth""" @@ -8,3 +15,16 @@ class AuthBase: def __init__(self, auth: Auth): self._auth = auth self._http = auth.http_client + + +class AsyncAuthBase: + """Base for async auth-method classes. + + Holds a sync Auth instance (used only for pure-computation helpers — + generate_jwt_response, validate_email, extract_masked_address, etc. — no I/O) + and an AsyncHTTPClient for all network calls. + """ + + def __init__(self, auth: Auth, http: AsyncHTTPClient): + self._auth = auth + self._http = http diff --git a/descope/_client_base.py b/descope/_client_base.py new file mode 100644 index 000000000..7d45b022f --- /dev/null +++ b/descope/_client_base.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import logging +import os +import warnings +from typing import Iterable + +import httpx + +from descope.auth import Auth +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.http_client import HTTPClient +from descope.management.common import MgmtV1 + +logger = logging.getLogger(__name__) + +LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 + + +class DescopeClientBase: + """ + Shared base for DescopeClient and AsyncDescopeClient. + + Handles: + - project_id validation and skip_verify warning + - Auth construction (via a sync HTTPClient for the one-time key fetch) + - All pure-computation validation helpers (no I/O) + """ + + def __init__( + self, + project_id: str, + public_key: dict | None, + skip_verify: bool, + timeout_seconds: float, + jwt_validation_leeway: int, + auth_management_key: str | None, + *, + base_url: str | None, + verbose: bool, + ): + project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") + if not project_id: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + ( + "Unable to init client because project_id cannot be empty. " + "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." + ), + ) + + if skip_verify: + warnings.warn( + "⚠️ SECURITY WARNING: TLS certificate verification is DISABLED (skip_verify=True). " + "This makes your application vulnerable to man-in-the-middle attacks. " + "ONLY use this for local development with self-signed certificates. " + "NEVER use skip_verify=True in production environments.", + category=UserWarning, + # stacklevel 3: warn → base.__init__ → subclass.__init__ → user code + stacklevel=3, + ) + + _auth_http = HTTPClient( + project_id=project_id, + base_url=base_url, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + verbose=verbose, + ) + self._auth = Auth(project_id, public_key, jwt_validation_leeway, http_client=_auth_http) + + # ------------------------------------------------------------------------- + # Argument-validation guards — reused by both DescopeClient and AsyncDescopeClient + # ------------------------------------------------------------------------- + + @staticmethod + def _ensure_present(value, message: str, error_type: str = ERROR_TYPE_INVALID_ARGUMENT) -> None: + """Raise AuthException(400, error_type, message) if *value* is falsy.""" + if not value: + raise AuthException(400, error_type, message) + + @staticmethod + def _require_refresh_token(refresh_token) -> None: + """Guard for ops that act on a refresh token (logout, me, history, my_tenants). + + Uses an ``is None`` check (not falsy) to preserve the historical error + message that echoes the token value. + """ + if refresh_token is None: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"signed refresh token {refresh_token} is empty", + ) + + @staticmethod + def _require_access_key(access_key) -> None: + """Guard for exchange_access_key.""" + if not access_key: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Access key cannot be empty") + + @staticmethod + def _validate_tenant_selector(dct: bool, ids) -> None: + """Guard for my_tenants: exactly one of *dct* or *ids* must be supplied.""" + if dct is True and ids is not None and len(ids) > 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") + if dct is False and (ids is None or len(ids) == 0): + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Only one of 'dct' or 'ids' should be supplied") + + def _fetch_rate_limit_tier(self, mgmt_http) -> None: + """Sync license handshake so the x-descope-license header is ready for the first mgmt call.""" + try: + response = httpx.get( + f"{mgmt_http.base_url}{MgmtV1.license_get_path}", + headers={"Authorization": f"Bearer {mgmt_http.project_id}:{mgmt_http.management_key}"}, + follow_redirects=True, + verify=mgmt_http.client_verify, + timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, + ) + if not response.is_success: + logger.warning( + "License handshake returned non-success status %s", + response.status_code, + ) + return + tier = response.json().get("rateLimitTier") + if tier: + mgmt_http.rate_limit_tier = tier + except Exception as e: + logger.warning("License handshake failed: %s", e) + + # ------------------------------------------------------------------------- + # Pure sync helpers — no I/O + # ------------------------------------------------------------------------- + + def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: + """ + Validate a session token. Pure CPU — no network I/O. + Call this for every incoming request to private endpoints. + """ + return self._auth.validate_session(session_token, audience) + + def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: + """ + Validate that jwt_response has been granted the specified permissions. + For multi-tenant use validate_tenant_permissions. + """ + return self.validate_tenant_permissions(jwt_response, "", permissions) + + def get_matched_permissions(self, jwt_response: dict, permissions: list[str]) -> list[str]: + """Return the subset of permissions that jwt_response has been granted.""" + return self.get_matched_tenant_permissions(jwt_response, "", permissions) + + def validate_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> bool: + """ + Validate that jwt_response has been granted the specified permissions on tenant. + Returns True only if all permissions are granted. + """ + if not jwt_response: + return False + + if isinstance(permissions, str): + permissions = [permissions] + + granted = [] + if tenant == "": + granted = jwt_response.get("permissions", []) + else: + if tenant not in jwt_response.get("tenants", {}): + return False + granted = jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) + + return all(p in granted for p in permissions) + + def get_matched_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> list[str]: + """Return the subset of permissions that jwt_response has been granted on tenant.""" + if not jwt_response: + return [] + + if isinstance(permissions, str): + permissions = [permissions] + + if tenant != "" and tenant not in jwt_response.get("tenants", {}): + return [] + + granted = ( + jwt_response.get("permissions", []) + if tenant == "" + else jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) + ) + return [p for p in permissions if p in granted] + + def validate_roles(self, jwt_response: dict, roles: list[str]) -> bool: + """ + Validate that jwt_response has been granted the specified roles. + For multi-tenant use validate_tenant_roles. + """ + return self.validate_tenant_roles(jwt_response, "", roles) + + def get_matched_roles(self, jwt_response: dict, roles: list[str]) -> list[str]: + """Return the subset of roles that jwt_response has been granted.""" + return self.get_matched_tenant_roles(jwt_response, "", roles) + + def validate_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> bool: + """ + Validate that jwt_response has been granted the specified roles on tenant. + Returns True only if all roles are granted. + """ + if not jwt_response: + return False + + if isinstance(roles, str): + roles = [roles] + + granted = [] + if tenant == "": + granted = jwt_response.get("roles", []) + else: + if tenant not in jwt_response.get("tenants", {}): + return False + granted = jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) + + return all(r in granted for r in roles) + + def get_matched_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> list[str]: + """Return the subset of roles that jwt_response has been granted on tenant.""" + if not jwt_response: + return [] + + if isinstance(roles, str): + roles = [roles] + + if tenant != "" and tenant not in jwt_response.get("tenants", {}): + return [] + + granted = ( + jwt_response.get("roles", []) + if tenant == "" + else jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) + ) + return [r for r in roles if r in granted] diff --git a/descope/_http_base.py b/descope/_http_base.py index 564dd8abe..bbf201c6c 100644 --- a/descope/_http_base.py +++ b/descope/_http_base.py @@ -1,10 +1,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from descope.http_client import HTTPClient +if TYPE_CHECKING: + from descope.async_http_client import AsyncHTTPClient + class HTTPBase: """Base class for classes that only need HTTP access.""" def __init__(self, http_client: HTTPClient): self._http = http_client + + +class AsyncHTTPBase: + """Base for async management classes.""" + + def __init__(self, http_client: AsyncHTTPClient): + self._http = http_client diff --git a/descope/async_descope_client.py b/descope/async_descope_client.py new file mode 100644 index 000000000..7df627f07 --- /dev/null +++ b/descope/async_descope_client.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import os +from typing import Iterable + +from descope._client_base import DescopeClientBase +from descope.async_http_client import AsyncHTTPClient +from descope.authmethod.async_totp import AsyncTOTP +from descope.common import ( + DEFAULT_TIMEOUT_SECONDS, + REFRESH_SESSION_COOKIE_NAME, + AccessKeyLoginOptions, + EndpointsV1, +) +from descope.exceptions import ( + ERROR_TYPE_INVALID_TOKEN, + AuthException, +) + + +class AsyncDescopeClient(DescopeClientBase): + """ + Async counterpart of DescopeClient. + + All network-bound operations are ``async def`` and must be awaited. + Pure JWT/session-validation operations (validate_session, + validate_permissions, etc.) are inherited sync from DescopeClientBase — + they perform no I/O. + + Usage (recommended — context manager): + async with AsyncDescopeClient(project_id="P...") as client: + jwt = await client.refresh_session(refresh_token) + + Usage (explicit close): + client = AsyncDescopeClient(project_id="P...") + try: + jwt = await client.refresh_session(refresh_token) + finally: + await client.aclose() + """ + + def __init__( + self, + project_id: str, + public_key: dict | None = None, + skip_verify: bool = False, + management_key: str | None = None, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + jwt_validation_leeway: int = 5, + auth_management_key: str | None = None, + fga_cache_url: str | None = None, + *, + base_url: str | None = None, + verbose: bool = False, + ): + super().__init__( + project_id, + public_key, + skip_verify, + timeout_seconds, + jwt_validation_leeway, + auth_management_key, + base_url=base_url, + verbose=verbose, + ) + + resolved_base_url = self._auth.http_client.base_url + self._auth_http = AsyncHTTPClient( + project_id=self._auth.project_id, + base_url=resolved_base_url, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + verbose=verbose, + ) + self._mgmt_http = AsyncHTTPClient( + project_id=self._auth.project_id, + base_url=resolved_base_url, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), + verbose=verbose, + ) + self._fga_cache_url = fga_cache_url + + self._totp = AsyncTOTP(self._auth, self._auth_http) + + if self._mgmt_http.management_key: + self._fetch_rate_limit_tier(self._mgmt_http) + + @property + def totp(self) -> AsyncTOTP: + return self._totp + + # ------------------------------------------------------------------------- + # Lifecycle + # ------------------------------------------------------------------------- + + async def aclose(self) -> None: + """Close the underlying async HTTP clients and release connections.""" + await self._auth_http.aclose() + await self._mgmt_http.aclose() + + async def __aenter__(self) -> AsyncDescopeClient: + return self + + async def __aexit__(self, *args) -> None: + await self.aclose() + + # ------------------------------------------------------------------------- + # Async session methods — network I/O + # ------------------------------------------------------------------------- + + async def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: + """Refresh a session using the refresh token. Makes an async network call.""" + self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) + # Validate token locally (pure CPU — may trigger a one-time sync key fetch on Auth) + self._auth._validate_token(refresh_token, audience) + response = await self._auth_http.post(EndpointsV1.refresh_token_path, body={}, pswd=refresh_token) + resp = response.json() + effective_refresh = response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None) or refresh_token + return self._auth.generate_jwt_response(resp, effective_refresh, audience) + + async def validate_and_refresh_session( + self, + session_token: str, + refresh_token: str, + audience: Iterable[str] | str | None = None, + ) -> dict: + """ + Validate the session token; refresh it if expired. + validate_session is sync (no I/O); refresh_session is async. + """ + self._ensure_present(session_token, "Session token is missing", ERROR_TYPE_INVALID_TOKEN) + try: + return self._auth.validate_session(session_token, audience) + except AuthException: + self._ensure_present(refresh_token, "Refresh token is missing", ERROR_TYPE_INVALID_TOKEN) + return await self.refresh_session(refresh_token, audience) + + async def logout(self, refresh_token: str): + """Logout the user from the current session and revoke the refresh token.""" + self._require_refresh_token(refresh_token) + return await self._auth_http.post(EndpointsV1.logout_path, body={}, pswd=refresh_token) + + async def logout_all(self, refresh_token: str): + """Logout the user from all active sessions and revoke the refresh token.""" + self._require_refresh_token(refresh_token) + return await self._auth_http.post(EndpointsV1.logout_all_path, body={}, pswd=refresh_token) + + async def me(self, refresh_token: str) -> dict: + """Retrieve user details for the refresh token.""" + self._require_refresh_token(refresh_token) + response = await self._auth_http.get(EndpointsV1.me_path, allow_redirects=None, pswd=refresh_token) + return response.json() + + async def my_tenants( + self, + refresh_token: str, + dct: bool = False, + ids: list[str] | None = None, + ) -> dict: + """Retrieve tenant attributes that the user belongs to.""" + self._require_refresh_token(refresh_token) + self._validate_tenant_selector(dct, ids) + + body: dict[str, bool | list[str]] = {"dct": dct} + if ids is not None: + body["ids"] = ids + response = await self._auth_http.post(EndpointsV1.my_tenants_path, body=body, pswd=refresh_token) + return response.json() + + async def history(self, refresh_token: str) -> list[dict]: + """Retrieve user authentication history for the refresh token.""" + self._require_refresh_token(refresh_token) + response = await self._auth_http.get(EndpointsV1.history_path, allow_redirects=None, pswd=refresh_token) + return response.json() + + async def exchange_access_key( + self, + access_key: str, + audience: Iterable[str] | str | None = None, + login_options: AccessKeyLoginOptions | None = None, + ) -> dict: + """Return a new session token for the given access key.""" + self._require_access_key(access_key) + body = { + "loginOptions": ( + {k: v for k, v in login_options.__dict__.items() if v is not None} if login_options else {} + ), + } + server_response = await self._auth_http.post( + EndpointsV1.exchange_auth_access_key_path, body=body, pswd=access_key + ) + return self._auth._generate_auth_info( + response_body=server_response.json(), + refresh_token=None, + user_jwt=False, + audience=audience, + ) + + async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict: + """Add a selected tenant claim to the JWT.""" + self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) + response = await self._auth_http.post( + EndpointsV1.select_tenant_path, body={"tenant": tenant_id}, pswd=refresh_token + ) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None + ) + + # ------------------------------------------------------------------------- + # Debugging + # ------------------------------------------------------------------------- + + def get_last_response(self): + """Get the last HTTP response when verbose mode is enabled.""" + mgmt_resp = self._mgmt_http.get_last_response() + auth_resp = self._auth_http.get_last_response() + return mgmt_resp or auth_resp diff --git a/descope/async_http_client.py b/descope/async_http_client.py new file mode 100644 index 000000000..ee153d0e2 --- /dev/null +++ b/descope/async_http_client.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +import contextvars +from typing import cast + +import httpx + +from descope.http_client import ( + _RETRY_DELAYS_SECONDS, + _RETRY_STATUS_CODES, + DescopeResponse, + HTTPClient, +) + + +class AsyncHTTPClient(HTTPClient): + def __init__( + self, + project_id: str, + base_url: str | None = None, + *, + timeout_seconds: float, + secure: bool, + management_key: str | None = None, + verbose: bool = False, + ) -> None: + super().__init__( + project_id, + base_url, + timeout_seconds=timeout_seconds, + secure=secure, + management_key=management_key, + verbose=verbose, + ) + self._async_client = httpx.AsyncClient( + verify=self.client_verify, + timeout=self.timeout_seconds, + ) + self._last_response_var: contextvars.ContextVar[DescopeResponse | None] = contextvars.ContextVar( + "descope_async_last_response", default=None + ) + + async def get( # type: ignore[override] + self, + uri: str, + *, + params=None, + allow_redirects: bool | None = True, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.get( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + params=params, + follow_redirects=cast(bool, allow_redirects), + ) + ) + if self.verbose: + self._last_response_var.set(DescopeResponse(response)) + self._raise_from_response(response) + return response + + async def post( # type: ignore[override] + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = None, + params=None, + pswd: str | None = None, + base_url: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.post( + f"{base_url or self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + if self.verbose: + self._last_response_var.set(DescopeResponse(response)) + self._raise_from_response(response) + return response + + async def put( # type: ignore[override] + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None = None, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.put( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + self._raise_from_response(response) + return response + + async def patch( # type: ignore[override] + self, + uri: str, + *, + body: dict | list[dict] | list[str] | None, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.patch( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + follow_redirects=False, + params=params, + ) + ) + if self.verbose: + self._last_response_var.set(DescopeResponse(response)) + self._raise_from_response(response) + return response + + async def delete( # type: ignore[override] + self, + uri: str, + *, + params=None, + pswd: str | None = None, + ) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.delete( + f"{self.base_url}{uri}", + params=params, + headers=self._get_default_headers(pswd), + follow_redirects=False, + ) + ) + if self.verbose: + self._last_response_var.set(DescopeResponse(response)) + self._raise_from_response(response) + return response + + def get_last_response(self) -> DescopeResponse | None: + """ + Get the last HTTP response for the current async task when verbose mode is enabled. + + Uses a ContextVar (not threading.local) so each concurrent async task sees its + own last response, even though all tasks share one event-loop thread. + """ + return self._last_response_var.get() + + async def _async_execute_with_retry(self, request_fn) -> httpx.Response: + response = await request_fn() + for delay in _RETRY_DELAYS_SECONDS: + if response.status_code not in _RETRY_STATUS_CODES: + break + await response.aclose() + await asyncio.sleep(delay) + response = await request_fn() + return response + + async def aclose(self) -> None: + await self._async_client.aclose() + + async def __aenter__(self) -> AsyncHTTPClient: + return self + + async def __aexit__(self, *args) -> None: + await self.aclose() diff --git a/descope/authmethod/_totp_base.py b/descope/authmethod/_totp_base.py new file mode 100644 index 000000000..19433879e --- /dev/null +++ b/descope/authmethod/_totp_base.py @@ -0,0 +1,60 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import Optional + +from descope.common import LoginOptions +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class TOTPBase: + """Shared, I/O-free base for TOTP auth-method classes. + + Holds only static validation guards and body composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``TOTP(TOTPBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``AsyncTOTP(TOTPBase, AsyncAuthBase)`` — async, uses ``self._http`` (``AsyncHTTPClient``) + """ + + # ------------------------------------------------------------------------- + # Argument-validation guards + # ------------------------------------------------------------------------- + + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + @staticmethod + def _validate_code(code: str) -> None: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Code cannot be empty") + + @staticmethod + def _validate_refresh_token(refresh_token: str) -> None: + if not refresh_token: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + + # ------------------------------------------------------------------------- + # Request body composers + # ------------------------------------------------------------------------- + + @staticmethod + def _compose_signup_body(login_id: str, user: Optional[dict]) -> dict: + body: dict[str, str | dict] = {"loginId": login_id} + if user is not None: + body["user"] = user + return body + + @staticmethod + def _compose_signin_body(login_id: str, code: str, login_options: Optional[LoginOptions] = None) -> dict: + return { + "loginId": login_id, + "code": code, + "loginOptions": login_options.__dict__ if login_options else {}, + } + + @staticmethod + def _compose_update_user_body(login_id: str) -> dict: + return {"loginId": login_id} diff --git a/descope/authmethod/async_totp.py b/descope/authmethod/async_totp.py new file mode 100644 index 000000000..2a4c5ab50 --- /dev/null +++ b/descope/authmethod/async_totp.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Iterable, Optional, Union + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._totp_base import TOTPBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) + + +class AsyncTOTP(TOTPBase, AsyncAuthBase): + """Async TOTP auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: + """Sign up a new user via TOTP; returns provisioningURL, image, and key.""" + self._validate_login_id(login_id) + + uri = EndpointsV1.sign_up_auth_totp_path + body = self._compose_signup_body(login_id, user) + response = await self._http.post(uri, body=body) + return response.json() + + async def sign_in_code( + self, + login_id: str, + code: str, + login_options: Optional[LoginOptions] = None, + refresh_token: Optional[str] = None, + audience: Union[str, None, Iterable[str]] = None, + ) -> dict: + """Verify a TOTP code and return session JWTs.""" + self._validate_login_id(login_id) + self._validate_code(code) + validate_refresh_token_provided(login_options, refresh_token) + + uri = EndpointsV1.verify_totp_path + body = self._compose_signin_body(login_id, code, login_options) + response = await self._http.post(uri, body=body, pswd=refresh_token) + + resp = response.json() + return self._auth.generate_jwt_response( + resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience + ) + + async def update_user(self, login_id: str, refresh_token: str) -> dict: + """Add TOTP to an existing user; returns provisioningURL, image, and key.""" + self._validate_login_id(login_id) + self._validate_refresh_token(refresh_token) + + uri = EndpointsV1.update_totp_path + body = self._compose_update_user_body(login_id) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return response.json() diff --git a/descope/authmethod/totp.py b/descope/authmethod/totp.py index 1b64f4c04..082551a78 100644 --- a/descope/authmethod/totp.py +++ b/descope/authmethod/totp.py @@ -1,16 +1,18 @@ +from __future__ import annotations + from typing import Iterable, Optional, Union from descope._auth_base import AuthBase +from descope.authmethod._totp_base import TOTPBase from descope.common import ( REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class TOTP(AuthBase): +class TOTP(TOTPBase, AuthBase): def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: """ Sign up (create) a new user using their email or phone number. @@ -31,14 +33,11 @@ def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: Raise: AuthException: raised if sign-up operation fails """ - - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) uri = EndpointsV1.sign_up_auth_totp_path - body = TOTP._compose_signup_body(login_id, user) + body = self._compose_signup_body(login_id, user) response = self._http.post(uri, body=body) - return response.json() def sign_in_code( @@ -66,24 +65,18 @@ def sign_in_code( Raise: AuthException: raised if the TOTP code is not valid or if code verification failed """ - - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Code cannot be empty") - + self._validate_login_id(login_id) + self._validate_code(code) validate_refresh_token_provided(login_options, refresh_token) uri = EndpointsV1.verify_totp_path - body = TOTP._compose_signin_body(login_id, code, login_options) + body = self._compose_signin_body(login_id, code, login_options) response = self._http.post(uri, body=body, pswd=refresh_token) resp = response.json() - jwt_response = self._auth.generate_jwt_response( + return self._auth.generate_jwt_response( resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience ) - return jwt_response def update_user(self, login_id: str, refresh_token: str) -> None: """ @@ -103,34 +96,10 @@ def update_user(self, login_id: str, refresh_token: str) -> None: Raise: AuthException: raised if refresh token is invalid or update operation fails """ - - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + self._validate_login_id(login_id) + self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_totp_path - body = TOTP._compose_update_user_body(login_id) + body = self._compose_update_user_body(login_id) response = self._http.post(uri, body=body, pswd=refresh_token) - return response.json() - - @staticmethod - def _compose_signup_body(login_id: str, user: Optional[dict]) -> dict: - body: dict[str, str | dict] = {"loginId": login_id} - if user is not None: - body["user"] = user - return body - - @staticmethod - def _compose_signin_body(login_id: str, code: str, login_options: Optional[LoginOptions] = None) -> dict: - return { - "loginId": login_id, - "code": code, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_update_user_body(login_id: str) -> dict: - return {"loginId": login_id} diff --git a/descope/descope_client.py b/descope/descope_client.py index 609bf1824..f47f93cec 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -2,11 +2,11 @@ import logging import os -import warnings from typing import Iterable import httpx +from descope._client_base import DescopeClientBase from descope.auth import Auth # noqa: F401 from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 from descope.authmethod.magiclink import MagicLink # noqa: F401 @@ -18,17 +18,13 @@ from descope.authmethod.totp import TOTP # noqa: F401 from descope.authmethod.webauthn import WebAuthn # noqa: F401 from descope.common import DEFAULT_TIMEOUT_SECONDS, AccessKeyLoginOptions, EndpointsV1 -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.http_client import HTTPClient -from descope.management.common import MgmtV1 from descope.mgmt import MGMT # noqa: F401 logger = logging.getLogger(__name__) -LICENSE_HANDSHAKE_TIMEOUT_SECONDS = 5.0 - -class DescopeClient: +class DescopeClient(DescopeClientBase): ALGORITHM_KEY = "alg" def __init__( @@ -45,44 +41,18 @@ def __init__( base_url: str | None = None, verbose: bool = False, ): - # validate project id - project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") - if not project_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - ( - "Unable to init DescopeClient because project_id cannot be empty. " - "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." - ), - ) - - # Warn about TLS verification bypass - if skip_verify: - warnings.warn( - "⚠️ SECURITY WARNING: TLS certificate verification is DISABLED (skip_verify=True). " - "This makes your application vulnerable to man-in-the-middle attacks. " - "ONLY use this for local development with self-signed certificates. " - "NEVER use skip_verify=True in production environments.", - category=UserWarning, - stacklevel=2, - ) - - # Auth Initialization - auth_http_client = HTTPClient( - project_id=project_id, - base_url=base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), - verbose=verbose, - ) - self._auth = Auth( + super().__init__( project_id, public_key, + skip_verify, + timeout_seconds, jwt_validation_leeway, - http_client=auth_http_client, + auth_management_key, + base_url=base_url, + verbose=verbose, ) + auth_http_client = self._auth.http_client + self._magiclink = MagicLink(self._auth) self._enchantedlink = EnchantedLink(self._auth) self._oauth = OAuth(self._auth) @@ -117,32 +87,7 @@ def __init__( # license-header validation for the GetLicense endpoint itself, so the # initial request is safe before the tier is cached. if mgmt_http_client.management_key: - self._fetch_rate_limit_tier() - - def _fetch_rate_limit_tier(self) -> None: - try: - response = httpx.get( - f"{self._mgmt_http_client.base_url}{MgmtV1.license_get_path}", - headers={ - "Authorization": ( - f"Bearer {self._mgmt_http_client.project_id}:{self._mgmt_http_client.management_key}" - ) - }, - follow_redirects=True, - verify=self._mgmt_http_client.client_verify, - timeout=LICENSE_HANDSHAKE_TIMEOUT_SECONDS, - ) - if not response.is_success: - logger.warning( - "License handshake returned non-success status %s", - response.status_code, - ) - return - tier = response.json().get("rateLimitTier") - if tier: - self._mgmt_http_client.rate_limit_tier = tier - except Exception as e: - logger.warning("License handshake failed: %s", e) + self._fetch_rate_limit_tier(mgmt_http_client) @property def mgmt(self): @@ -187,209 +132,6 @@ def webauthn(self): def password(self): return self._password - def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: - """ - Validate that a jwt_response has been granted the specified permissions. - For a multi-tenant environment use validate_tenant_permissions function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - permissions (List[str]): List of permissions to validate for this jwt_response - - Return value (bool): returns true if all permissions granted; false if at least one permission not granted - """ - return self.validate_tenant_permissions(jwt_response, "", permissions) - - def get_matched_permissions(self, jwt_response: dict, permissions: list[str]) -> list[str]: - """ - Get the list of permissions that a jwt_response has been granted from the provided list of permissions. - For a multi-tenant environment use get_matched_tenant_permissions function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - permissions (List[str]): List of permissions to validate for this jwt_response - - Return value (List[str]): returns the list of permissions that are granted - """ - return self.get_matched_tenant_permissions(jwt_response, "", permissions) - - def validate_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> bool: - """ - Validate that a jwt_response has been granted the specified permissions on the specified tenant. - For a multi-tenant environment use validate_tenant_permissions function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - tenant (str): TenantId - permissions (List[str]): List of permissions to validate for this jwt_response - - Return value (bool): returns true if all permissions granted; false if at least one permission not granted - """ - if not jwt_response: - return False - - if isinstance(permissions, str): - permissions = [permissions] - - granted = [] - if tenant == "": - granted = jwt_response.get("permissions", []) - else: - # ensure that the tenant is associated with the jwt_response - if tenant not in jwt_response.get("tenants", {}): - return False - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) - - for perm in permissions: - if perm not in granted: - return False - return True - - def get_matched_tenant_permissions(self, jwt_response: dict, tenant: str, permissions: list[str]) -> list[str]: - """ - Get the list of permissions that a jwt_response has been granted from the provided list of permissions on the specified tenant. - For a multi-tenant environment use get_matched_tenant_permissions function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - tenant (str): TenantId - permissions (List[str]): List of permissions to validate for this jwt_response - - Return value (List[str]): returns the list of permissions that are granted - """ - if not jwt_response: - return [] - - if isinstance(permissions, str): - permissions = [permissions] - - granted = [] - if tenant == "": - granted = jwt_response.get("permissions", []) - else: - # ensure that the tenant is associated with the jwt_response - if tenant not in jwt_response.get("tenants", {}): - return [] - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("permissions", []) - - matched = [] - for perm in permissions: - if perm in granted: - matched.append(perm) - return matched - - def validate_roles(self, jwt_response: dict, roles: list[str]) -> bool: - """ - Validate that a jwt_response has been granted the specified roles. - For a multi-tenant environment use validate_tenant_roles function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - roles (List[str]): List of roles to validate for this jwt_response - - Return value (bool): returns true if all roles granted; false if at least one role not granted - """ - return self.validate_tenant_roles(jwt_response, "", roles) - - def get_matched_roles(self, jwt_response: dict, roles: list[str]) -> list[str]: - """ - Get the list of roles that a jwt_response has been granted from the provided list of roles. - For a multi-tenant environment use get_matched_tenant_roles function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - roles (List[str]): List of roles to validate for this jwt_response - - Return value (List[str]): returns the list of roles that are granted - """ - return self.get_matched_tenant_roles(jwt_response, "", roles) - - def validate_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> bool: - """ - Validate that a jwt_response has been granted the specified roles on the specified tenant. - For a multi-tenant environment use validate_tenant_roles function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - tenant (str): TenantId - roles (List[str]): List of roles to validate for this jwt_response - - Return value (bool): returns true if all roles granted; false if at least one role not granted - """ - if not jwt_response: - return False - - if isinstance(roles, str): - roles = [roles] - - granted = [] - if tenant == "": - granted = jwt_response.get("roles", []) - else: - # ensure that the tenant is associated with the jwt_response - if tenant not in jwt_response.get("tenants", {}): - return False - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) - - for role in roles: - if role not in granted: - return False - return True - - def get_matched_tenant_roles(self, jwt_response: dict, tenant: str, roles: list[str]) -> list[str]: - """ - Get the list of roles that a jwt_response has been granted from the provided list of roles on the specified tenant. - For a multi-tenant environment use get_matched_tenant_roles function - - Args: - jwt_response (dict): The jwt_response object which includes all JWT claims information - tenant (str): TenantId - roles (List[str]): List of roles to validate for this jwt_response - - Return value (List[str]): returns the list of roles that are granted - """ - if not jwt_response: - return [] - - if isinstance(roles, str): - roles = [roles] - - granted = [] - if tenant == "": - granted = jwt_response.get("roles", []) - else: - # ensure that the tenant is associated with the jwt_response - if tenant not in jwt_response.get("tenants", {}): - return [] - granted = jwt_response.get("tenants", {}).get(tenant, {}).get("roles", []) - - matched = [] - for role in roles: - if role in granted: - matched.append(role) - return matched - - def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: - """ - Validate a session token. Call this function for every incoming request to your - private endpoints. Alternatively, use validate_and_refresh_session in order to - automatically refresh expired sessions. If you need to use these specific claims - [amr, drn, exp, iss, rexp, sub, jwt] in the top level of the response dict, please use - them from the sessionToken key instead, as these claims will soon be deprecated from the top level - of the response dict. - - Args: - session_token (str): The session token to be validated - audience (str|Iterable[str]|None): Optional recipients that the JWT is intended for (must be equal to the 'aud' claim on the provided token) - - Return value (dict): - Return dict includes the session token and all JWT claims - - Raise: - AuthException: Exception is raised if session is not authorized or any other error occurs - """ - return self._auth.validate_session(session_token, audience) - def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: """ Refresh a session. Call this function when a session expires and needs to be refreshed. @@ -444,13 +186,7 @@ def logout(self, refresh_token: str) -> httpx.Response: Raise: AuthException: Exception is raised if session is not authorized or another error occurs """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - + self._require_refresh_token(refresh_token) uri = EndpointsV1.logout_path return self._auth.http_client.post(uri, body={}, pswd=refresh_token) @@ -467,13 +203,7 @@ def logout_all(self, refresh_token: str) -> httpx.Response: Raise: AuthException: Exception is raised if session is not authorized or another error occurs """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - + self._require_refresh_token(refresh_token) uri = EndpointsV1.logout_all_path return self._auth.http_client.post(uri, body={}, pswd=refresh_token) @@ -491,13 +221,7 @@ def me(self, refresh_token: str) -> dict: Raise: AuthException: Exception is raised if session is not authorized or another error occurs """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - + self._require_refresh_token(refresh_token) uri = EndpointsV1.me_path response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) return response.json() @@ -522,24 +246,8 @@ def my_tenants( Raise: AuthException: Exception is raised if session is not authorized or another error occurs """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - if dct is True and ids is not None and len(ids) > 0: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Only one of 'dct' or 'ids' should be supplied", - ) - if dct is False and (ids is None or len(ids) == 0): - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Only one of 'dct' or 'ids' should be supplied", - ) + self._require_refresh_token(refresh_token) + self._validate_tenant_selector(dct, ids) body: dict[str, bool | list[str]] = {"dct": dct} if ids is not None: @@ -571,13 +279,7 @@ def history(self, refresh_token: str) -> list[dict]: Raise: AuthException: Exception is raised if session is not authorized or another error occurs """ - if refresh_token is None: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"signed refresh token {refresh_token} is empty", - ) - + self._require_refresh_token(refresh_token) uri = EndpointsV1.history_path response = self._auth.http_client.get(uri=uri, allow_redirects=None, pswd=refresh_token) return response.json() @@ -601,9 +303,7 @@ def exchange_access_key( Raise: AuthException: Exception is raised if access key is not valid or another error occurs """ - if not access_key: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Access key cannot be empty") - + self._require_access_key(access_key) return self._auth.exchange_access_key(access_key, audience, login_options) def select_tenant( diff --git a/pyproject.toml b/pyproject.toml index 385e0421e..c7e8e65bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ types = [ # mypy 1.12+ requires Python 3.10+; on 3.9 we stay on the last 1.11.x line. # mypy is only run in the lint job (Python 3.13) so 3.9 never installs it in CI. "mypy>=1.20.1; python_version >= '3.10'", - "mypy==2.1.0; python_version < '3.10'", + "mypy==1.11.2; python_version < '3.10'", ] tests = [ # pytest 9 requires Python 3.10+; on 3.9 we stay on the last 8.x line. @@ -59,6 +59,8 @@ tests = [ "pytest>=9.0.3; python_version >= '3.10'", "pytest>=8.4,<9; python_version < '3.10'", "pytest-cov>=5", + "pytest-asyncio==1.2.0; python_version < '3.10'", + "pytest-asyncio==1.4.0; python_version >= '3.10'", "coverage[toml]>=7.3.1,<8", ] @@ -73,6 +75,9 @@ module-root = "" requires = ["uv_build>=0.11.7,<0.12.0"] build-backend = "uv_build" +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.coverage.run] relative_files = true source = ["descope"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..232de300c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import asyncio +import os +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from descope.async_descope_client import AsyncDescopeClient +from descope.descope_client import DescopeClient +from tests.common import DEFAULT_BASE_URL + +# --------------------------------------------------------------------------- +# Shared test constants +# --------------------------------------------------------------------------- + +PROJECT_ID = "dummy" + +# ES384 key — kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW, used by the JWT test tokens throughout +# test_descope_client.py and test_descope_client_unified.py. +PUBLIC_KEY_DICT = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", + "kty": "EC", + "use": "sig", + "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", + "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", +} + + +# --------------------------------------------------------------------------- +# Response factory +# --------------------------------------------------------------------------- + + +def make_response(json_data=None, *, status=200, cookies=None): + """Build a mock httpx.Response usable as the return value of a mocked HTTP call.""" + m = MagicMock() + m.is_success = status < 400 + m.status_code = status + m.json.return_value = json_data or {} + cm = MagicMock() + cm.get = MagicMock(side_effect=lambda k, d=None: (cookies or {}).get(k, d)) + m.cookies = cm + m.headers = {} + m.text = str(json_data or "") + return m + + +# --------------------------------------------------------------------------- +# UnifiedClient — mode-agnostic wrapper for sync / async clients +# --------------------------------------------------------------------------- + + +class UnifiedClient: + """ + Wraps DescopeClient or AsyncDescopeClient with a uniform interface so test + bodies can run unchanged against both variants. + + - ``invoke(maybe_coro)`` — awaits async calls, passes through sync values. + - ``mock_get/mock_post(response)`` — patches the right HTTP layer per mode. + """ + + def __init__(self, mode: str, raw): + self.mode = mode # "sync" | "async" + self._raw = raw + + def __getattr__(self, name): + return getattr(self._raw, name) + + # --- Execution --- + + async def invoke(self, maybe_coro): + """Uniformly run a sync return value or an async coroutine.""" + if asyncio.iscoroutine(maybe_coro): + return await maybe_coro + return maybe_coro + + # --- Mock helpers --- + + @contextmanager + def mock_get(self, response): + with self._patch_ctx("get", response) as m: + yield m + + @contextmanager + def mock_post(self, response): + with self._patch_ctx("post", response) as m: + yield m + + # --- Internals --- + + def _patch_ctx(self, method: str, response): + """ + Patch the right layer per mode: + + - sync → ``httpx.`` (the module-level function HTTPClient calls). + - async → ``_async_client.`` on the AsyncHTTPClient instance. + """ + if self.mode == "sync": + return patch(f"httpx.{method}", return_value=response) + return patch.object( + self._raw._auth_http._async_client, + method, + AsyncMock(return_value=response), + ) + + +# --------------------------------------------------------------------------- +# ClientFactory — for tests that need custom construction arguments +# --------------------------------------------------------------------------- + + +class ClientFactory: + """ + Use via the ``client_factory`` fixture when a test must control construction + arguments directly (bad project_id, jwt_validation_leeway, auth_management_key…). + + ``make(*args, **kwargs)`` returns a UnifiedClient on success or propagates + the construction exception — so tests that expect failure just wrap the call + in ``pytest.raises``. + """ + + def __init__(self, mode: str): + self.mode = mode + self._async_clients: list = [] # tracked so teardown can aclose them + + def make(self, *args, **kwargs) -> "UnifiedClient": + """Construct a (Async)DescopeClient and wrap it in UnifiedClient.""" + if self.mode == "sync": + return UnifiedClient("sync", DescopeClient(*args, **kwargs)) + client = AsyncDescopeClient(*args, **kwargs) + self._async_clients.append(client) + return UnifiedClient("async", client) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(params=["sync", "async"]) +async def descope_client(request): + """ + Parametrized fixture — yields a UnifiedClient wrapping DescopeClient (sync) + or AsyncDescopeClient (async). Each consuming test runs twice. + """ + # Save and restore DESCOPE_BASE_URI so it doesn't leak into other tests. + _prev = os.environ.get("DESCOPE_BASE_URI") + os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL + try: + if request.param == "sync": + yield UnifiedClient("sync", DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT)) + else: + raw = AsyncDescopeClient(PROJECT_ID, PUBLIC_KEY_DICT) + yield UnifiedClient("async", raw) + await raw.aclose() # release the underlying httpx.AsyncClient cleanly + finally: + if _prev is None: + os.environ.pop("DESCOPE_BASE_URI", None) + else: + os.environ["DESCOPE_BASE_URI"] = _prev + + +@pytest.fixture(params=["sync", "async"]) +async def client_factory(request): + """ + Parametrized factory fixture — yields a ClientFactory so tests can + construct clients with custom arguments (bad keys, leeway, mgmt key, …). + Each consuming test runs twice (sync + async). + """ + _prev = os.environ.get("DESCOPE_BASE_URI") + os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL + factory = ClientFactory(request.param) + try: + yield factory + finally: + for raw in factory._async_clients: + await raw.aclose() + if _prev is None: + os.environ.pop("DESCOPE_BASE_URI", None) + else: + os.environ["DESCOPE_BASE_URI"] = _prev diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py new file mode 100644 index 000000000..b70aa9300 --- /dev/null +++ b/tests/test_async_http_client.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from descope.async_http_client import AsyncHTTPClient +from descope.exceptions import AuthException, RateLimitException +from descope.http_client import _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES +from tests.testutils import SSLMatcher + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +_DEFAULT_BASE_URL = "https://api.descope.com" + + +def make_async_client(*, secure=True, verbose=False, project_id="test123", base_url=_DEFAULT_BASE_URL): + """Build an AsyncHTTPClient with a mocked _async_client (no real socket). + + base_url is passed explicitly so tests are never affected by the + DESCOPE_BASE_URI env var that unittest-based tests leave set. + """ + with patch("descope.async_http_client.httpx.AsyncClient"): + return AsyncHTTPClient( + project_id=project_id, + base_url=base_url, + timeout_seconds=60, + secure=secure, + verbose=verbose, + ) + + +def make_resp(*, status=200, json_data=None, headers=None, text=""): + """Build a mock httpx.Response for async tests.""" + r = MagicMock() + r.is_success = status < 400 + r.status_code = status + r.json.return_value = json_data or {} + r.headers = headers or {} + r.text = text + r.aclose = AsyncMock() # awaited by the retry loop on retryable failures + return r + + +# --------------------------------------------------------------------------- +# 1. Init — AsyncClient is constructed with right verify/timeout +# --------------------------------------------------------------------------- + + +class TestAsyncHTTPClientInit: + def test_secure_passes_ssl_context(self): + with patch("descope.async_http_client.httpx.AsyncClient") as mock_cls: + AsyncHTTPClient(project_id="test123", timeout_seconds=30, secure=True) + _, kwargs = mock_cls.call_args + assert kwargs["verify"] == SSLMatcher() + assert kwargs["timeout"] == 30 + + def test_insecure_passes_false(self): + with patch("descope.async_http_client.httpx.AsyncClient") as mock_cls: + AsyncHTTPClient(project_id="test123", timeout_seconds=10, secure=False) + _, kwargs = mock_cls.call_args + assert kwargs["verify"] == SSLMatcher(insecure=True) + + def test_empty_project_id_raises(self): + with patch("descope.async_http_client.httpx.AsyncClient"): + with pytest.raises(AuthException) as exc_info: + AsyncHTTPClient(project_id="", timeout_seconds=30, secure=True) + assert exc_info.value.status_code == 400 + + +# --------------------------------------------------------------------------- +# 2. Verbs — each verb forwards the right URL, headers, body, params +# --------------------------------------------------------------------------- + + +class TestAsyncHTTPClientVerbs: + async def test_get(self): + client = make_async_client(project_id="test123") + client._async_client.get = AsyncMock(return_value=make_resp(json_data={"ok": 1})) + + await client.get("/path", params={"q": "1"}, allow_redirects=False, pswd="tok") + + call = client._async_client.get.await_args + assert call.args[0] == "https://api.descope.com/path" + assert call.kwargs["params"] == {"q": "1"} + assert call.kwargs["follow_redirects"] is False + assert "Bearer test123:tok" in call.kwargs["headers"]["Authorization"] + + async def test_get_default_allow_redirects(self): + client = make_async_client() + client._async_client.get = AsyncMock(return_value=make_resp()) + + await client.get("/path") + + call = client._async_client.get.await_args + assert call.kwargs["follow_redirects"] is True # default allow_redirects=True + + async def test_post(self): + client = make_async_client(project_id="test123") + client._async_client.post = AsyncMock(return_value=make_resp()) + + await client.post("/create", body={"name": "x"}, params={"a": "b"}, pswd="tok") + + call = client._async_client.post.await_args + assert call.args[0] == "https://api.descope.com/create" + assert call.kwargs["json"] == {"name": "x"} + assert call.kwargs["params"] == {"a": "b"} + assert call.kwargs["follow_redirects"] is False + assert "Bearer test123:tok" in call.kwargs["headers"]["Authorization"] + + async def test_post_base_url_override(self): + client = make_async_client() + client._async_client.post = AsyncMock(return_value=make_resp()) + + await client.post("/ep", body={}, base_url="https://custom.example.com") + + url = client._async_client.post.await_args.args[0] + assert url == "https://custom.example.com/ep" + + async def test_put(self): + client = make_async_client(project_id="test123") + client._async_client.put = AsyncMock(return_value=make_resp()) + + await client.put("/update", body={"val": 1}, params={"k": "v"}, pswd="tok") + + call = client._async_client.put.await_args + assert call.args[0] == "https://api.descope.com/update" + assert call.kwargs["json"] == {"val": 1} + assert call.kwargs["follow_redirects"] is False + + async def test_patch(self): + client = make_async_client(project_id="test123") + client._async_client.patch = AsyncMock(return_value=make_resp()) + + await client.patch("/edit", body={"x": 2}, pswd="tok") + + call = client._async_client.patch.await_args + assert call.args[0] == "https://api.descope.com/edit" + assert call.kwargs["json"] == {"x": 2} + assert call.kwargs["follow_redirects"] is False + + async def test_delete(self): + client = make_async_client(project_id="test123") + client._async_client.delete = AsyncMock(return_value=make_resp()) + + await client.delete("/remove", params={"id": "1"}, pswd="tok") + + call = client._async_client.delete.await_args + assert call.args[0] == "https://api.descope.com/remove" + assert call.kwargs["params"] == {"id": "1"} + assert call.kwargs["follow_redirects"] is False + + +# --------------------------------------------------------------------------- +# 3. Retry — mirrors TestRetryMechanism from test_http_client.py +# --------------------------------------------------------------------------- + + +class TestAsyncRetry: + async def test_retries_on_retryable_codes(self): + for status_code in _RETRY_STATUS_CODES: + client = make_async_client() + err = make_resp(status=status_code) + ok = make_resp(status=200) + + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + client._async_client.get = AsyncMock(side_effect=[err, ok]) + resp = await client.get("/x") + + assert client._async_client.get.await_count == 2, f"Should retry once on {status_code}" + assert resp.status_code == 200 + mock_sleep.assert_awaited_once_with(0.1) + err.aclose.assert_awaited_once() + + async def test_retries_to_exhaustion_raises(self): + client = make_async_client() + err = make_resp(status=503, text="Unavailable") + + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + client._async_client.get = AsyncMock(return_value=err) + with pytest.raises(AuthException): + await client.get("/x") + + # original + 3 retries = 4 total calls + assert client._async_client.get.await_count == 4 + assert mock_sleep.await_count == 3 + + async def test_retry_delay_sequence(self): + client = make_async_client() + err = make_resp(status=503, text="Unavailable") + sleep_calls = [] + + async def fake_sleep(delay): + sleep_calls.append(delay) + + with patch("descope.async_http_client.asyncio.sleep", fake_sleep): + client._async_client.get = AsyncMock(return_value=err) + with pytest.raises(AuthException): + await client.get("/x") + + assert sleep_calls == list(_RETRY_DELAYS_SECONDS) + + async def test_no_retry_on_non_retryable_codes(self): + for status_code in [400, 401, 403, 404, 500, 502]: + client = make_async_client() + err = make_resp(status=status_code, text=f"Error {status_code}") + + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + client._async_client.get = AsyncMock(return_value=err) + with pytest.raises(AuthException): + await client.get("/x") + + assert client._async_client.get.await_count == 1, f"Should not retry on {status_code}" + mock_sleep.assert_not_awaited() + + async def test_prior_response_closed_before_retry(self): + client = make_async_client() + err1 = make_resp(status=503) + err2 = make_resp(status=503) + ok = make_resp(status=200) + + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) + await client.get("/x") + + err1.aclose.assert_awaited_once() + err2.aclose.assert_awaited_once() + ok.aclose.assert_not_awaited() + + async def test_success_on_first_attempt_no_retry(self): + client = make_async_client() + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + client._async_client.get = AsyncMock(return_value=ok) + await client.get("/x") + assert client._async_client.get.await_count == 1 + mock_sleep.assert_not_awaited() + + async def test_retry_succeeds_on_third_attempt(self): + client = make_async_client() + err1 = make_resp(status=503) + err2 = make_resp(status=503) + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) + resp = await client.get("/x") + assert resp.status_code == 200 + assert client._async_client.get.await_count == 3 + + async def test_retry_works_for_post(self): + client = make_async_client() + err = make_resp(status=503) + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.post = AsyncMock(side_effect=[err, ok]) + resp = await client.post("/x", body={}) + assert resp.status_code == 200 + assert client._async_client.post.await_count == 2 + + async def test_retry_works_for_put(self): + client = make_async_client() + err = make_resp(status=503) + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.put = AsyncMock(side_effect=[err, ok]) + resp = await client.put("/x", body={}) + assert resp.status_code == 200 + assert client._async_client.put.await_count == 2 + + async def test_retry_works_for_patch(self): + client = make_async_client() + err = make_resp(status=503) + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.patch = AsyncMock(side_effect=[err, ok]) + resp = await client.patch("/x", body={}) + assert resp.status_code == 200 + assert client._async_client.patch.await_count == 2 + + async def test_retry_works_for_delete(self): + client = make_async_client() + err = make_resp(status=503) + ok = make_resp(status=200) + with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + client._async_client.delete = AsyncMock(side_effect=[err, ok]) + resp = await client.delete("/x") + assert resp.status_code == 200 + assert client._async_client.delete.await_count == 2 + + +# --------------------------------------------------------------------------- +# 4. Verbose mode +# --------------------------------------------------------------------------- + + +class TestAsyncVerbose: + async def test_get_captures_response_when_verbose(self): + client = make_async_client(verbose=True) + client._async_client.get = AsyncMock( + return_value=make_resp(status=200, json_data={"d": 1}, headers={"cf-ray": "r1"}) + ) + + await client.get("/x") + + last = client.get_last_response() + assert last is not None + assert last.status_code == 200 + assert last.headers.get("cf-ray") == "r1" + + async def test_get_does_not_capture_when_not_verbose(self): + client = make_async_client(verbose=False) + client._async_client.get = AsyncMock(return_value=make_resp()) + + await client.get("/x") + + assert client.get_last_response() is None + + async def test_post_captures_response_when_verbose(self): + client = make_async_client(verbose=True) + client._async_client.post = AsyncMock( + return_value=make_resp(status=201, json_data={"id": "u1"}, headers={"cf-ray": "r2"}) + ) + + await client.post("/x", body={}) + + last = client.get_last_response() + assert last is not None + assert last.status_code == 201 + + async def test_patch_captures_response_when_verbose(self): + client = make_async_client(verbose=True) + client._async_client.patch = AsyncMock( + return_value=make_resp(status=200, json_data={"ok": 1}, headers={"cf-ray": "r3"}) + ) + + await client.patch("/x", body={}) + + last = client.get_last_response() + assert last is not None + assert last.status_code == 200 + + async def test_delete_captures_response_when_verbose(self): + client = make_async_client(verbose=True) + client._async_client.delete = AsyncMock( + return_value=make_resp(status=200, json_data={"gone": 1}, headers={"cf-ray": "r4"}) + ) + + await client.delete("/x") + + last = client.get_last_response() + assert last is not None + assert last.status_code == 200 + + +# --------------------------------------------------------------------------- +# 5. Error raising — inherited _raise_from_response fires after await +# --------------------------------------------------------------------------- + + +class TestAsyncErrors: + async def test_raises_auth_exception_on_500(self): + client = make_async_client() + client._async_client.get = AsyncMock(return_value=make_resp(status=500, text="Error")) + + with pytest.raises(AuthException) as exc_info: + await client.get("/x") + + assert exc_info.value.status_code == 500 + + async def test_raises_rate_limit_exception_on_429(self): + client = make_async_client() + client._async_client.get = AsyncMock( + return_value=make_resp( + status=429, + json_data={"errorCode": "E010", "errorDescription": "Rate limit exceeded"}, + headers={"Retry-After": "60"}, + ) + ) + + with pytest.raises(RateLimitException) as exc_info: + await client.get("/x") + + assert exc_info.value.error_type == "API rate limit exceeded" + + async def test_raises_rate_limit_when_json_fails(self): + client = make_async_client() + bad = make_resp(status=429, headers={"Retry-After": "30"}) + bad.json.side_effect = ValueError("bad json") + client._async_client.get = AsyncMock(return_value=bad) + + with pytest.raises(RateLimitException): + await client.get("/x") + + +# --------------------------------------------------------------------------- +# 6. Lifecycle — aclose and context manager +# --------------------------------------------------------------------------- + + +class TestAsyncLifecycle: + async def test_aclose_delegates_to_async_client(self): + client = make_async_client() + client._async_client.aclose = AsyncMock() + + await client.aclose() + + client._async_client.aclose.assert_awaited_once() + + async def test_context_manager_yields_client_and_closes(self): + with patch("descope.async_http_client.httpx.AsyncClient"): + async with AsyncHTTPClient(project_id="test123", timeout_seconds=60, secure=True) as c: + assert isinstance(c, AsyncHTTPClient) + c._async_client.aclose = AsyncMock() + + c._async_client.aclose.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# 7. Headers — management key propagation +# --------------------------------------------------------------------------- + + +class TestAsyncHTTPClientHeaders: + async def test_management_key_in_authorization_header(self): + """auth_management_key is baked into the Authorization header on every verb call.""" + with patch("descope.async_http_client.httpx.AsyncClient"): + client = AsyncHTTPClient( + project_id="proj123", + timeout_seconds=60, + secure=True, + management_key="mgmt-key", + ) + client._async_client.get = AsyncMock(return_value=make_resp(json_data={"ok": 1})) + await client.get("/path") + call = client._async_client.get.await_args + assert call.kwargs["headers"]["Authorization"] == "Bearer proj123:mgmt-key" diff --git a/tests/test_descope_client_parity.py b/tests/test_descope_client_parity.py new file mode 100644 index 000000000..558959d6d --- /dev/null +++ b/tests/test_descope_client_parity.py @@ -0,0 +1,1008 @@ +""" +Parity port of test_descope_client.py using the unified sync/async fixture infrastructure. + +Structure mirrors the original: one class, one test method per feature. Each method +runs twice — once for the sync DescopeClient and once for AsyncDescopeClient — via +pytest's parametrised ``descope_client`` / ``client_factory`` fixtures from conftest. + +Tests that exercise surfaces not yet ported to AsyncDescopeClient (mgmt, otp, oauth…) +call ``pytest.skip()`` in async mode so the original assertions are preserved verbatim. +""" + +from __future__ import annotations + +import json +import sys +from copy import deepcopy +from unittest import mock +from unittest.mock import patch + +import pytest + +from descope import ( + API_RATE_LIMIT_RETRY_AFTER_HEADER, + ERROR_TYPE_API_RATE_LIMIT, + SESSION_COOKIE_NAME, + AccessKeyLoginOptions, + AuthException, + RateLimitException, +) +from descope.common import ( + DEFAULT_TIMEOUT_SECONDS, + SESSION_TOKEN_NAME, + DeliveryMethod, + EndpointsV1, +) +from tests.conftest import PROJECT_ID, PUBLIC_KEY_DICT, make_response +from tests.testutils import SSLMatcher + +from . import common + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +PUBLIC_KEY_STR = json.dumps(PUBLIC_KEY_DICT) + +# The original setUp public_key_dict (kid=2Bt5…) used by a handful of tests +DUMMY_PUBLIC_KEY_DICT = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", +} + +# JWT tokens (all signed with kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW) +VALID_REFRESH_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" + "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" + ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" + "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" +) + +VALID_SESSION_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" + "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" + ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" + "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" +) + +# drn=DS, exp=1659644298 (past) +EXPIRED_SESSION_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3" + "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" + ".wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvD" + "NgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" +) + +EXPECTED_USER_ID = "U2CuCPuJgPWHGB5P4GmfbuPGhGVm" +EXPECTED_PROJECT_ID = "P2CuC9yv2UGtGI1o84gCZEb9qEQW" + +# Tokens ported from test_descope_client.py that must fail validate_session +_INVALID_HEADER_TOKEN = ( + "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0" + ".Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGP" + "HPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" +) +_MISSING_KID_TOKEN = ( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0" + ".eyJleHAiOjE5ODEzOTgxMTF9" + ".GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP" + "3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" +) +_INVALID_PAYLOAD_TOKEN = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" + + +# --------------------------------------------------------------------------- +# Helper: mode-aware HTTP call assertion +# --------------------------------------------------------------------------- + + +def assert_http_called(mock_http, mode, url, **kwargs): + """Assert the patched HTTP mock was called with the given arguments. + + In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode + they are set on the ``httpx.AsyncClient`` constructor and absent from each call. + This helper injects them automatically for sync so test bodies stay identical. + """ + if mode == "sync": + kwargs.setdefault("verify", SSLMatcher()) + kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) + mock_http.assert_called_with(url, **kwargs) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestDescopeClient: + # ------------------------------------------------------------------ + # Construction validation + # ------------------------------------------------------------------ + + async def test_descope_client(self, client_factory): + with pytest.raises(AuthException): + client_factory.make(None, "dummy") + with pytest.raises(AuthException): + client_factory.make("", "dummy") + + with patch("os.getenv") as mock_getenv: + mock_getenv.return_value = "" + with pytest.raises(AuthException): + client_factory.make(None, "dummy") + + assert client_factory.make(PROJECT_ID, None) is not None + assert client_factory.make(PROJECT_ID, "") is not None + with pytest.raises(AuthException): + client_factory.make(PROJECT_ID, "not dict object") + assert client_factory.make(PROJECT_ID, PUBLIC_KEY_STR) is not None + + async def test_project_id_from_env_without_env(self, client_factory): + with patch.dict("os.environ", {"DESCOPE_PROJECT_ID": ""}): + with pytest.raises(AuthException): + client_factory.make("") + + # ------------------------------------------------------------------ + # Management client (sync-only) + # ------------------------------------------------------------------ + + async def test_mgmt(self, descope_client): + if descope_client.mode != "sync": + pytest.skip("mgmt not available on AsyncDescopeClient") + + # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set + with pytest.raises(AuthException): + _ = descope_client.mgmt.tenant + with pytest.raises(AuthException): + _ = descope_client.mgmt.sso_application + with pytest.raises(AuthException): + _ = descope_client.mgmt.user + with pytest.raises(AuthException): + _ = descope_client.mgmt.access_key + with pytest.raises(AuthException): + _ = descope_client.mgmt.sso + with pytest.raises(AuthException): + _ = descope_client.mgmt.jwt + with pytest.raises(AuthException): + _ = descope_client.mgmt.permission + with pytest.raises(AuthException): + _ = descope_client.mgmt.role + with pytest.raises(AuthException): + _ = descope_client.mgmt.group + with pytest.raises(AuthException): + _ = descope_client.mgmt.flow + with pytest.raises(AuthException): + _ = descope_client.mgmt.audit + with pytest.raises(AuthException): + _ = descope_client.mgmt.authz + with pytest.raises(AuthException): + _ = descope_client.mgmt.fga + with pytest.raises(AuthException): + _ = descope_client.mgmt.project + with pytest.raises(AuthException): + _ = descope_client.mgmt.outbound_application + + # Validate that outbound_application_by_token doesn't require mgmt key + try: + _ = descope_client.mgmt.outbound_application_by_token + except AuthException: + pytest.fail("failed to initiate outbound_application_by_token without management key") + + # ------------------------------------------------------------------ + # logout / logout_all + # ------------------------------------------------------------------ + + async def test_logout(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout(None)) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout("")) + + with descope_client.mock_post(make_response(status=200)): + assert await descope_client.invoke(descope_client.logout("")) is not None + + async def test_logout_all(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout_all(None)) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout_all("")) + + with descope_client.mock_post(make_response(status=200)): + assert await descope_client.invoke(descope_client.logout_all("")) is not None + + # ------------------------------------------------------------------ + # me + # ------------------------------------------------------------------ + + async def test_me(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.me(None)) + + with descope_client.mock_get(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.me("")) + + data = json.loads("""{"name": "Testy McTester", "email": "testy@tester.com"}""") + with descope_client.mock_get(make_response(data)) as mock_get: + user_response = await descope_client.invoke(descope_client.me("")) + assert user_response is not None + assert data["name"] == user_response["name"] + assert_http_called( + mock_get, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.me_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + follow_redirects=None, + params=None, + ) + + # ------------------------------------------------------------------ + # my_tenants + # ------------------------------------------------------------------ + + async def test_my_tenants(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants(None)) + + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("")) + + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("", True, ["a"])) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("", True)) + + data = json.loads("""{"tenants": [{"id": "tenant_id", "name": "tenant_name"}]}""") + with descope_client.mock_post(make_response(data)) as mock_post: + tenant_response = await descope_client.invoke(descope_client.my_tenants("", False, ["a"])) + assert tenant_response is not None + assert data["tenants"][0]["name"] == tenant_response["tenants"][0]["name"] + assert_http_called( + mock_post, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.my_tenants_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + json={"dct": False, "ids": ["a"]}, + follow_redirects=False, + params=None, + ) + + # ------------------------------------------------------------------ + # history + # ------------------------------------------------------------------ + + async def test_history(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.history(None)) + + with descope_client.mock_get(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.history("")) + + data = json.loads( + """ + [ + { + "userId": "kuku", + "city": "kefar saba", + "country": "Israel", + "ip": "1.1.1.1", + "loginTime": 32 + }, + { + "userId": "nunu", + "city": "eilat", + "country": "Israele", + "ip": "1.1.1.2", + "loginTime": 23 + } + ] + """ + ) + with descope_client.mock_get(make_response(data)) as mock_get: + user_response = await descope_client.invoke(descope_client.history("")) + assert user_response is not None + assert data == user_response + assert_http_called( + mock_get, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.history_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + follow_redirects=None, + params=None, + ) + + # ------------------------------------------------------------------ + # validate_session — pure-CPU helper (no IO) + # ------------------------------------------------------------------ + + async def test_validate_session(self, client_factory): + # Client with the 2Bt5 key (matching the kid in _INVALID_PAYLOAD_TOKEN) + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) + + with pytest.raises(AuthException): + client.validate_session(_MISSING_KID_TOKEN) + with pytest.raises(AuthException): + client.validate_session(_INVALID_HEADER_TOKEN) + with pytest.raises(AuthException): + client.validate_session(_INVALID_PAYLOAD_TOKEN) + + # None key client + None token + client4 = client_factory.make(PROJECT_ID, None) + with pytest.raises(AuthException): + client4.validate_session(None) + + async def test_validate_session_response_structure(self, descope_client): + result = descope_client.validate_session(VALID_SESSION_TOKEN) + assert result == { + "drn": "DS", + "exp": 2493061415, + "iat": 1659643061, + "iss": EXPECTED_PROJECT_ID, + "sub": EXPECTED_USER_ID, + "jwt": VALID_SESSION_TOKEN, + "permissions": [], + "roles": [], + "tenants": {}, + "projectId": EXPECTED_PROJECT_ID, + "userId": EXPECTED_USER_ID, + "sessionToken": { + "drn": "DS", + "exp": 2493061415, + "iat": 1659643061, + "iss": EXPECTED_PROJECT_ID, + "sub": EXPECTED_USER_ID, + "jwt": VALID_SESSION_TOKEN, + }, + } + + async def test_validate_session_valid_tokens(self, client_factory): + # Client with P2Cu key preloaded + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + dummy_refresh_token = "refresh" + valid_jwt_token = VALID_REFRESH_TOKEN # far-future DSR token, P2Cu kid + + # Valid token validates locally — no network needed + client.validate_session(valid_jwt_token) + + assert ( + await client.invoke(client.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) is not None + ) + + # Key id cannot be found — key fetch returns wrong kid + client2 = client_factory.make(PROJECT_ID, None) + with patch("httpx.get") as mock_request: + fake_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) + fake_key["kid"] = "dummy_kid" + mock_request.return_value.text = json.dumps([fake_key]) + mock_request.return_value.is_success = True + with pytest.raises(AuthException): + await client2.invoke(client2.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) + + # Key fetch returns unparsable key + client3 = client_factory.make(PROJECT_ID, None) + with patch("httpx.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.is_success = True + with pytest.raises(AuthException): + await client3.invoke(client3.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) + + # header_alg != key[alg] + bad_alg_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) + bad_alg_key["alg"] = "ES521" + client4 = client_factory.make(PROJECT_ID, bad_alg_key) + with patch("httpx.get") as mock_request: + mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" + mock_request.return_value.is_success = True + with pytest.raises(AuthException): + await client4.invoke(client4.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) + + # Both session_token and refresh_token are None + client4b = client_factory.make(PROJECT_ID, None) + with pytest.raises(AuthException): + await client4b.invoke(client4b.validate_and_refresh_session(None, None)) + + # Expired session triggers refresh; refreshed token is also expired → fails + expired_jwt_token = EXPIRED_SESSION_TOKEN + valid_refresh_for_expire_test = valid_jwt_token + with patch("httpx.get") as mock_request: + mock_request.return_value.cookies = {SESSION_COOKIE_NAME: expired_jwt_token} + mock_request.return_value.is_success = True + with pytest.raises(AuthException): + await client3.invoke( + client3.validate_and_refresh_session(expired_jwt_token, valid_refresh_for_expire_test) + ) + + # ------------------------------------------------------------------ + # Exception object shapes (no client needed) + # ------------------------------------------------------------------ + + def test_exception_object(self): + ex = AuthException(401, "dummy-type", "dummy error message") + assert str(ex) is not None + assert repr(ex) is not None + assert ex.status_code == 401 + assert ex.error_type == "dummy-type" + assert ex.error_message == "dummy error message" + + def test_api_rate_limit_exception_object(self): + ex = RateLimitException( + 429, + ERROR_TYPE_API_RATE_LIMIT, + "API rate limit exceeded description", + "API rate limit exceeded", + {API_RATE_LIMIT_RETRY_AFTER_HEADER: "9"}, + ) + assert str(ex) is not None + assert repr(ex) is not None + assert ex.status_code == 429 + assert ex.error_type == ERROR_TYPE_API_RATE_LIMIT + assert ex.error_description == "API rate limit exceeded description" + assert ex.error_message == "API rate limit exceeded" + assert ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, "") == "9" + + # ------------------------------------------------------------------ + # Expired token + refresh flows + # ------------------------------------------------------------------ + + async def test_expired_token(self, client_factory): + # expired DS token (kid=P2Cu, exp=1657798328 — past) + expired_jwt_token = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9" + ".eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9" + ".i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" + ) + dummy_refresh_token = "dummy refresh token" + + # Client with P2Cu key (same kid the validate tokens use in refresh path) + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Fail flow: key is preloaded so validate_session raises due to expiration + with patch("httpx.get") as mock_request: + mock_request.return_value.is_success = False + with pytest.raises(AuthException): + client.validate_session(expired_jwt_token) + + with patch("httpx.get") as mock_request: + mock_request.return_value.cookies = {"aaa": "aaa"} + mock_request.return_value.is_success = True + with pytest.raises(AuthException): + client.validate_session(expired_jwt_token) + + # Fail flow: jwt.get_unverified_header returns {} (no kid) + dummy_session_token = "dummy session token" + # dummy_client has the 2Bt5 key; EXPIRED_SESSION_TOKEN uses P2Cu — key not loaded + dummy_client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) + with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: + mock_jwt_get_unverified_header.return_value = {} + with pytest.raises(AuthException): + await dummy_client.invoke( + dummy_client.validate_and_refresh_session(dummy_session_token, dummy_refresh_token) + ) + + # Success flow: expired token → POST refresh → returns valid new session token + new_session_token = VALID_SESSION_TOKEN + valid_refresh_token = VALID_REFRESH_TOKEN + expired_token = EXPIRED_SESSION_TOKEN + resp = make_response({"sessionJwt": new_session_token}, cookies={}) + with client.mock_post(resp): + # Refresh because of expiration + result = await client.invoke(client.validate_and_refresh_session(expired_token, valid_refresh_token)) + new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] + assert new_session_token_from_request == new_session_token, "Failed to refresh token" + + # Refresh explicitly + result = await client.invoke(client.refresh_session(valid_refresh_token)) + new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] + assert new_session_token_from_request == new_session_token, "Failed to refresh token" + + # Fail flow: refreshed token is also expired → AuthException + # dummy_client has P2Cu key; expired_jwt_token (kid=2Bt5) is NOT preloaded → triggers + # JWKS fetch via httpx.get; mock returns garbage JSON → AuthException + expired_jwt_token2 = EXPIRED_SESSION_TOKEN + valid_refresh_token2 = VALID_REFRESH_TOKEN + new_refreshed_token = expired_jwt_token2 + with patch("httpx.get") as mock_request: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"sessionJwt": new_refreshed_token} + mock_request.return_value = my_mock_response + mock_request.return_value.cookies = {} + with pytest.raises(AuthException): + await dummy_client.invoke( + dummy_client.validate_and_refresh_session(expired_jwt_token2, valid_refresh_token2) + ) + + # ------------------------------------------------------------------ + # Public key loading errors + # ------------------------------------------------------------------ + + async def test_public_key_load(self, client_factory): + # Test key without kty property + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) + invalid_public_key.pop("kty") + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 + + # Test key without kid property + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) + invalid_public_key.pop("kid") + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 + + # Test key with unknown algorithm + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) + invalid_public_key["alg"] = "unknown algorithm" + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 + + # ------------------------------------------------------------------ + # Client property surface + # ------------------------------------------------------------------ + + async def test_client_properties(self, descope_client): + # totp is available on both sync and async clients + assert descope_client.totp is not None, "Empty totp object" + + # All other auth-method properties are sync-only + if descope_client.mode != "sync": + return + assert descope_client.magiclink is not None, "Empty Magiclink object" + assert descope_client.otp is not None, "Empty otp object" + assert descope_client.oauth is not None, "Empty oauth object" + assert descope_client.saml is not None, "Empty saml object" + assert descope_client.sso is not None, "Empty saml object" + assert descope_client.webauthn is not None, "Empty webauthN object" + + # ------------------------------------------------------------------ + # Permission / role helpers — pure-CPU + # ------------------------------------------------------------------ + + async def test_validate_permissions(self, descope_client): + jwt_response = {} + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False + + jwt_response = {"permissions": []} + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False + assert descope_client.validate_permissions(jwt_response, []) is True + + jwt_response = {"permissions": ["Perm 1"]} + assert descope_client.validate_permissions(jwt_response, "Perm 1") is True + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is True + assert descope_client.validate_permissions(jwt_response, ["Perm 2"]) is False + + # Tenant level + jwt_response = {"tenants": {}} + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False + + jwt_response = {"tenants": {"t1": {}}} + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False + + jwt_response = {"tenants": {"t1": {"permissions": "Perm 1"}}} + assert descope_client.validate_tenant_permissions(jwt_response, "t1", []) is True + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1"]) is True + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False + assert descope_client.validate_tenant_permissions(jwt_response, "t2", []) is False + + async def test_get_matched_permissions(self, descope_client): + jwt_response = {} + assert descope_client.get_matched_permissions(jwt_response, []) == [] + + jwt_response = {"permissions": []} + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == [] + + jwt_response = {"permissions": ["Perm 1", "Perm 2"]} + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == ["Perm 1"] + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2"]) == ["Perm 1", "Perm 2"] + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2", "Perm 3"]) == [ + "Perm 1", + "Perm 2", + ] + + # Tenant level + jwt_response = {"tenants": {}} + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] + + jwt_response = {"tenants": {"t1": {}}} + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] + + jwt_response = {"tenants": {"t1": {"permissions": ["Perm 1", "Perm 2"]}}} + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == ["Perm 1"] + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) == [ + "Perm 1", + "Perm 2", + ] + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2", "Perm 3"]) == [ + "Perm 1", + "Perm 2", + ] + + async def test_validate_roles(self, descope_client): + jwt_response = {} + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False + + jwt_response = {"roles": []} + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False + assert descope_client.validate_roles(jwt_response, []) is True + + jwt_response = {"roles": ["Role 1"]} + assert descope_client.validate_roles(jwt_response, "Role 1") is True + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is True + assert descope_client.validate_roles(jwt_response, ["Role 2"]) is False + + # Tenant level + jwt_response = {"tenants": {}} + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False + + jwt_response = {"tenants": {"t1": {}}} + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False + + jwt_response = {"tenants": {"t1": {"roles": "Role 1"}}} + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1"]) is True + assert descope_client.validate_tenant_roles(jwt_response, "t1", []) is True + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 2"]) is False + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) is False + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False + + async def test_get_matched_roles(self, descope_client): + jwt_response = {} + assert descope_client.get_matched_roles(jwt_response, []) == [] + + jwt_response = {"roles": []} + assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == [] + + jwt_response = {"roles": ["Role 1", "Role 2"]} + assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == ["Role 1"] + assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] + assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2", "Role 3"]) == ["Role 1", "Role 2"] + + # Tenant level + jwt_response = {"tenants": {}} + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] + + jwt_response = {"tenants": {"t1": {}}} + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] + + jwt_response = {"tenants": {"t1": {"roles": ["Role 1", "Role 2"]}}} + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == ["Role 1"] + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2", "Role 3"]) == [ + "Role 1", + "Role 2", + ] + + # ------------------------------------------------------------------ + # exchange_access_key + # ------------------------------------------------------------------ + + async def test_exchange_access_key_empty_param(self, descope_client): + with pytest.raises(AuthException) as exc_info: + await descope_client.invoke(descope_client.exchange_access_key("")) + assert exc_info.value.status_code == 400 + + async def test_exchange_access_key(self, descope_client): + dummy_access_key = "dummy access key" + resp = make_response({"sessionJwt": VALID_REFRESH_TOKEN}) + with descope_client.mock_post(resp) as mock_post: + jwt_response = await descope_client.invoke( + descope_client.exchange_access_key( + access_key=dummy_access_key, + login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), + ) + ) + assert jwt_response["keyId"] == EXPECTED_USER_ID + assert jwt_response["projectId"] == EXPECTED_PROJECT_ID + assert_http_called( + mock_post, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:dummy access key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginOptions": {"customClaims": {"k1": "v1"}}}, + follow_redirects=False, + ) + + # ------------------------------------------------------------------ + # JWT validation leeway + # ------------------------------------------------------------------ + + async def test_jwt_validation_leeway(self, client_factory): + # Negative leeway forces even far-future tokens to appear expired + min_int = -sys.maxsize - 1 + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, jwt_validation_leeway=min_int) + + with pytest.raises(AuthException) as exc_info: + client.validate_session(VALID_REFRESH_TOKEN) + assert exc_info.value.status_code == 400 + assert "nbf in future" in exc_info.value.error_message + + # ------------------------------------------------------------------ + # select_tenant + # ------------------------------------------------------------------ + + async def test_select_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + resp = make_response(data) + with client.mock_post(resp) as mock_post: + await client.invoke(client.select_tenant("t1", VALID_REFRESH_TOKEN)) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.select_tenant_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{VALID_REFRESH_TOKEN}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"tenant": "t1"}, + follow_redirects=False, + ) + + # ------------------------------------------------------------------ + # auth_management_key header propagation (sync-only: uses otp) + # ------------------------------------------------------------------ + + async def test_auth_management_key_with_functions(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("otp not available on AsyncDescopeClient") + + auth_mgmt_key = "test-auth-mgmt-key" + + # Test 1: Direct auth_management_key setting (without refresh token) + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) + + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + client.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", + headers={ + **common.default_headers, + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{auth_mgmt_key}", + }, + json={ + "loginId": "test@example.com", + "user": {"email": "test@example.com"}, + "email": "test@example.com", + }, + params=None, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test 2: Environment variable auth_management_key setting + env_auth_mgmt_key = "env-auth-mgmt-key" + with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): + client_env = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) + + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + client_env.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", + headers={ + **common.default_headers, + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{env_auth_mgmt_key}", + }, + json={ + "loginId": "test@example.com", + "user": {"email": "test@example.com"}, + "email": "test@example.com", + }, + follow_redirects=False, + params=None, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test 3: Direct parameter takes priority over environment variable + direct_auth_mgmt_key = "direct-auth-mgmt-key" + with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): + client_priority = client_factory.make( + PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=direct_auth_mgmt_key + ) + + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + + client_priority.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", + headers={ + **common.default_headers, + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{direct_auth_mgmt_key}", + }, + json={ + "loginId": "test@example.com", + "user": {"email": "test@example.com"}, + "email": "test@example.com", + }, + params=None, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + async def test_auth_management_key_with_refresh_token(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("otp not available on AsyncDescopeClient") + + auth_mgmt_key = "test-auth-mgmt-key" + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) + + # Test with refresh token function + refresh_token = "test_refresh_token" + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "n***@example.com"} + mock_post.return_value = my_mock_response + + client.otp.update_user_email("old@example.com", "new@example.com", refresh_token) + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}:{auth_mgmt_key}", + "x-descope-project-id": PROJECT_ID, + }, + json={ + "loginId": "old@example.com", + "email": "new@example.com", + "addToLoginIDs": False, + "onMergeUseExisting": False, + }, + follow_redirects=False, + params=None, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Without auth_management_key — refresh token only in Authorization + client_no_auth = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "n***@example.com"} + mock_post.return_value = my_mock_response + + client_no_auth.otp.update_user_email("old@example.com", "new@example.com", refresh_token) + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + json={ + "loginId": "old@example.com", + "email": "new@example.com", + "addToLoginIDs": False, + "onMergeUseExisting": False, + }, + follow_redirects=False, + params=None, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # ------------------------------------------------------------------ + # base_url parameter + # ------------------------------------------------------------------ + + async def test_base_url_setting(self, client_factory): + custom_base_url = "https://api.use1.descope.com" + client = client_factory.make(PROJECT_ID, base_url=custom_base_url, public_key=PUBLIC_KEY_DICT) + + # Auth HTTP client base_url is available on both sync and async + assert client._auth.http_client.base_url == custom_base_url + + # Management HTTP client is sync-only + if client_factory.mode == "sync": + assert client._mgmt._http.base_url == custom_base_url + + async def test_base_url_none(self, client_factory): + client = client_factory.make(PROJECT_ID, base_url=None, public_key=PUBLIC_KEY_DICT) + + expected_base_url = common.DEFAULT_BASE_URL + assert client._auth.http_client.base_url == expected_base_url + + if client_factory.mode == "sync": + assert client._mgmt._http.base_url == expected_base_url + + # ------------------------------------------------------------------ + # Verbose mode + # ------------------------------------------------------------------ + + async def test_verbose_mode_disabled_by_default(self, client_factory): + client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT) + assert client.get_last_response() is None + + async def test_verbose_mode_enabled(self, client_factory): + client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT, verbose=True) + # Just verify it doesn't error when enabled + assert client.get_last_response() is None # No requests made yet + + async def test_verbose_mode_captures_mgmt_response(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("mgmt not available on AsyncDescopeClient") + + mock_response = mock.Mock() + mock_response.is_success = True + mock_response.json.return_value = {"user": {"id": "u1", "loginIds": ["test@example.com"]}} + mock_response.headers = {"cf-ray": "mgmt-ray-123", "x-request-id": "req-456"} + mock_response.status_code = 200 + + with patch("httpx.post", return_value=mock_response): + client = client_factory.make( + PROJECT_ID, + public_key=PUBLIC_KEY_DICT, + management_key="test-mgmt-key", + verbose=True, + ) + client.mgmt.user.create(login_id="test@example.com") + + last_resp = client.get_last_response() + assert last_resp is not None + assert last_resp["user"]["id"] == "u1" + assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" + assert last_resp.status_code == 200 diff --git a/tests/test_totp_parity.py b/tests/test_totp_parity.py new file mode 100644 index 000000000..2cd5a049d --- /dev/null +++ b/tests/test_totp_parity.py @@ -0,0 +1,219 @@ +""" +Parity port of test_totp.py using the unified sync/async fixture infrastructure. + +Structure mirrors the original: one class, one test method per operation. +Each method runs twice — once for sync DescopeClient and once for AsyncDescopeClient — +via pytest's parametrised ``client_factory`` fixture from conftest. + +Payload assertions (assert_http_called) are included where the original had them: + - test_sign_in: refresh-token call body + headers + - test_update_user: call body + headers + - test_sign_up: asserts result is not None (original had no payload assertion) +""" + +from __future__ import annotations + +import pytest + +from descope import AuthException +from descope.common import ( + DEFAULT_TIMEOUT_SECONDS, + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, PUBLIC_KEY_DICT, make_response +from tests.testutils import SSLMatcher + +from . import common + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +# drn=DSR, exp=2264443061 (far future) — signed with PUBLIC_KEY_DICT (P2Cu kid) +VALID_REFRESH_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" + "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" + ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" + "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" +) + +# drn=DS, exp=2493061415 (far future) — signed with PUBLIC_KEY_DICT (P2Cu kid) +VALID_SESSION_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" + "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" + ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" + "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" +) + + +# --------------------------------------------------------------------------- +# Helper: mode-aware HTTP call assertion (same as test_descope_client_parity.py) +# --------------------------------------------------------------------------- + + +def assert_http_called(mock_http, mode, url, **kwargs): + """Assert the patched HTTP mock was called with the given arguments. + + In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode + they are set on the ``httpx.AsyncClient`` constructor and absent from each call. + This helper injects them automatically for sync so test bodies stay identical. + """ + if mode == "sync": + kwargs.setdefault("verify", SSLMatcher()) + kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) + mock_http.assert_called_with(url, **kwargs) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTOTP: + # ------------------------------------------------------------------ + # sign_up + # ------------------------------------------------------------------ + + async def test_sign_up(self, client_factory): + signup_user_details = { + "username": "jhon", + "name": "john", + "phone": "972525555555", + "email": "dummy@dummy.com", + } + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up("", signup_user_details)) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up(None, signup_user_details)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) + + # Success + data = {"provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01"} + with client.mock_post(make_response(data)): + result = await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) + assert result is not None + + # ------------------------------------------------------------------ + # sign_in_code + # ------------------------------------------------------------------ + + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = "dummy refresh token" + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code(None, "1234")) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("", "1234")) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", None)) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) + + # Success + MFA-without-refresh check + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp): + result = await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) + assert result is not None + # MFA stepup requires a refresh token — omitting it must raise + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "code", LoginOptions(mfa=True))) + + # Verify refresh token propagates correctly into the request + with client.mock_post(success_resp) as mock_post: + await client.invoke( + client.totp.sign_in_code( + "dummy@dummy.com", + "1234", + LoginOptions(stepup=True), + refresh_token=refresh_token, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_totp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "code": "1234", + "loginOptions": { + "stepup": True, + "customClaims": None, + "mfa": False, + }, + }, + follow_redirects=False, + ) + + # ------------------------------------------------------------------ + # update_user + # ------------------------------------------------------------------ + + async def test_update_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + valid_refresh_token = VALID_REFRESH_TOKEN + valid_response = { + "provisioningURL": "http://dummy.com", + "image": "imagedata", + "key": "k01", + "error": "", + } + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user(None, "")) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("", "")) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", None)) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", "dummy refresh token")) + + # Success + payload assertion + with client.mock_post(make_response(valid_response)) as mock_post: + res = await client.invoke(client.totp.update_user("dummy@dummy.com", valid_refresh_token)) + assert res == valid_response + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_totp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{valid_refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com"}, + follow_redirects=False, + ) diff --git a/uv.lock b/uv.lock index dfc1199b3..0f58d0e48 100644 --- a/uv.lock +++ b/uv.lock @@ -84,6 +84,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -660,6 +669,8 @@ tests = [ { name = "coverage", version = "7.14.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, ] types = [ @@ -686,6 +697,8 @@ tests = [ { name = "coverage", extras = ["toml"], specifier = ">=7.3.1,<8" }, { name = "pytest", marker = "python_full_version < '3.10'", specifier = ">=8.4,<9" }, { name = "pytest", marker = "python_full_version >= '3.10'", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", marker = "python_full_version < '3.10'", specifier = "==1.2.0" }, + { name = "pytest-asyncio", marker = "python_full_version >= '3.10'", specifier = "==1.4.0" }, { name = "pytest-cov", specifier = ">=5" }, ] types = [ @@ -695,11 +708,11 @@ types = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/b2/d6fc3f2347f43dada79e5ff118493e8109c98400a0e29a1d5264a3aa479b/distlib-0.4.1.tar.gz", hash = "sha256:c3804d0d2d4b5fcd44036eb860cb6660485fcdf5c2aba53dc324d805837ea65b", size = 610526, upload-time = "2026-06-02T11:17:40.691Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/25/18/3497c4fa83a76dcb154923fd2075522e8dd6995ecee4093c00ae18160046/distlib-0.4.1-py2.py3-none-any.whl", hash = "sha256:9c2c552c68cbadc619f2d0ed3a69e27c351a3f4c9baa9ffb7df9e9cdc3d19a97", size = 469216, upload-time = "2026-06-02T11:17:38.779Z" }, ] [[package]] @@ -769,15 +782,15 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", "python_full_version >= '3.10' and python_full_version < '3.15'", ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, ] [[package]] @@ -865,11 +878,11 @@ wheels = [ [[package]] name = "idna" -version = "3.17" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1411,6 +1424,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.10' and python_full_version < '3.15'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" @@ -1433,7 +1482,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "filelock", version = "3.29.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] @@ -1610,7 +1659,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "filelock", version = "3.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "filelock", version = "3.29.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "platformdirs", version = "4.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "python-discovery" }, From 9072ce7cad8470580f48dd553727873aea81a031 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:05:14 +0300 Subject: [PATCH 02/17] Update uv.lock --- uv.lock | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/uv.lock b/uv.lock index 0f58d0e48..105c82d7c 100644 --- a/uv.lock +++ b/uv.lock @@ -691,7 +691,7 @@ provides-extras = ["flask"] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = "==3.8.0" }, - { name = "ruff", specifier = "==0.15.12" }, + { name = "ruff", specifier = "==0.15.13" }, ] tests = [ { name = "coverage", extras = ["toml"], specifier = ">=7.3.1,<8" }, @@ -1566,27 +1566,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] From 2d54ba26246aaccf56b8fb33d35be509a3ddb655 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:08:04 +0300 Subject: [PATCH 03/17] lints --- tests/conftest.py | 2 +- tests/test_descope_client_parity.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 232de300c..5cca39b4b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,7 +127,7 @@ def __init__(self, mode: str): self.mode = mode self._async_clients: list = [] # tracked so teardown can aclose them - def make(self, *args, **kwargs) -> "UnifiedClient": + def make(self, *args, **kwargs) -> UnifiedClient: """Construct a (Async)DescopeClient and wrap it in UnifiedClient.""" if self.mode == "sync": return UnifiedClient("sync", DescopeClient(*args, **kwargs)) diff --git a/tests/test_descope_client_parity.py b/tests/test_descope_client_parity.py index 558959d6d..02c1d1ea1 100644 --- a/tests/test_descope_client_parity.py +++ b/tests/test_descope_client_parity.py @@ -748,6 +748,7 @@ async def test_jwt_validation_leeway(self, client_factory): with pytest.raises(AuthException) as exc_info: client.validate_session(VALID_REFRESH_TOKEN) assert exc_info.value.status_code == 400 + assert exc_info.value.error_message is not None assert "nbf in future" in exc_info.value.error_message # ------------------------------------------------------------------ From 621751b3aae711b4db235d40adcedca700f33032 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:10:22 +0300 Subject: [PATCH 04/17] thats ruff --- tests/test_async_http_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py index b70aa9300..0d4f14fc9 100644 --- a/tests/test_async_http_client.py +++ b/tests/test_async_http_client.py @@ -9,7 +9,6 @@ from descope.http_client import _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES from tests.testutils import SSLMatcher - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- From 3d9f4e84a604a5ea6b67ef0cda64320c48d0300a Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:59:55 +0300 Subject: [PATCH 05/17] format --- descope/authmethod/async_totp.py | 4 +--- descope/authmethod/totp.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/descope/authmethod/async_totp.py b/descope/authmethod/async_totp.py index 2a4c5ab50..d4acc5850 100644 --- a/descope/authmethod/async_totp.py +++ b/descope/authmethod/async_totp.py @@ -42,9 +42,7 @@ async def sign_in_code( response = await self._http.post(uri, body=body, pswd=refresh_token) resp = response.json() - return self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) async def update_user(self, login_id: str, refresh_token: str) -> dict: """Add TOTP to an existing user; returns provisioningURL, image, and key.""" diff --git a/descope/authmethod/totp.py b/descope/authmethod/totp.py index 082551a78..4bc1f99b2 100644 --- a/descope/authmethod/totp.py +++ b/descope/authmethod/totp.py @@ -74,9 +74,7 @@ def sign_in_code( response = self._http.post(uri, body=body, pswd=refresh_token) resp = response.json() - return self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) def update_user(self, login_id: str, refresh_token: str) -> None: """ From f7b418c5838a055636da2ba1eb9b06d60af6e230 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:19:25 +0300 Subject: [PATCH 06/17] other --- .vscode/settings.json | 10 +- async-plan-tests.md | 555 ++++++++++++++++++ async-plan.md | 443 ++++++++++++++ descope/__init__.py | 4 +- descope/_auth_base.py | 4 +- descope/_client_base.py | 4 +- descope/_http_base.py | 4 +- descope/_http_client_base.py | 256 ++++++++ .../{async_totp.py => totp_async.py} | 2 +- ...cope_client.py => descope_client_async.py} | 20 +- descope/http_client.py | 242 +------- ...nc_http_client.py => http_client_async.py} | 25 +- tests/conftest.py | 10 +- tests/test_descope_client_parity.py | 12 +- tests/test_http_client.py | 10 +- ...tp_client.py => test_http_client_async.py} | 50 +- tests/test_totp_parity.py | 2 +- 17 files changed, 1348 insertions(+), 305 deletions(-) create mode 100644 async-plan-tests.md create mode 100644 async-plan.md create mode 100644 descope/_http_client_base.py rename descope/authmethod/{async_totp.py => totp_async.py} (98%) rename descope/{async_descope_client.py => descope_client_async.py} (94%) rename descope/{async_http_client.py => http_client_async.py} (92%) rename tests/{test_async_http_client.py => test_http_client_async.py} (91%) diff --git a/.vscode/settings.json b/.vscode/settings.json index a6e85ca89..9c3c5bec9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,6 @@ { "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": [ - "tests" - ], + "python.testing.pytestArgs": ["tests"], "ruff.importStrategy": "fromEnvironment", "mypy-type-checker.importStrategy": "fromEnvironment", "[python]": { @@ -13,5 +11,9 @@ "source.organizeImports.ruff": "explicit" } }, - "workbench.colorCustomizations": { /* do not change please... */} + "workbench.colorCustomizations": { + /* do not change please... */ + }, + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.defaultPackageManager": "ms-python.python:pip" } diff --git a/async-plan-tests.md b/async-plan-tests.md new file mode 100644 index 000000000..7ac971309 --- /dev/null +++ b/async-plan-tests.md @@ -0,0 +1,555 @@ +# Test Refactoring Plan (v2) — Unified Sync/Async Testing + +## Goal + +Run 90% of existing tests against both `DescopeClient` (sync) and `AsyncDescopeClient` +(async) with zero duplication of test logic. The remaining 10% covers mode-specific +concerns: client initialization, HTTP transport mechanics, and lifecycle (aclose, context manager). +The 98% coverage requirement must be maintained throughout. + +--- + +## Core problem & solution + +### The fundamental challenge + +You cannot `await` inside a sync function, so a test body written for a sync client +can't directly call an async client. Two building blocks solve this completely: + +**1. `invoke()` — run sync or async calls uniformly from `async def` tests** + +```python +async def invoke(self, maybe_coro): + if asyncio.iscoroutine(maybe_coro): + return await maybe_coro + return maybe_coro +``` + +- Sync client: `client.otp.sign_in(...)` executes immediately, returns a value. `invoke(value)` wraps and returns it. +- Async client: `client.otp.sign_in(...)` returns an unawaited coroutine. `invoke(coro)` awaits it. + +For exception tests: sync raises during argument evaluation (before `invoke` is called); +async raises when the coroutine runs inside `invoke`. Both are caught by the surrounding +`pytest.raises` context manager. No special casing needed. + +**2. `UnifiedClient` — abstracts sync/async construction, client access, and mock setup** + +Wraps either `DescopeClient` or `AsyncDescopeClient`, providing a uniform interface +to the test body so tests never branch on mode. + +### Test depth trade-off + +Current tests mock at `httpx.post` (module level) and assert on the full HTTP call +(URL, headers, JSON body). This pattern tests two things at once: +1. The auth method builds the right URI path and request body +2. `HTTPClient.post` correctly assembles the final `httpx.post` call + +Unifying these two layers across sync and async is possible but requires different +`assert_called_with` signatures (sync includes `verify=SSLMatcher(), timeout=...`; +async doesn't, since those are on the client level). + +**Decision**: Unified tests verify behavioral correctness (right return value, right +exceptions). Exact HTTP request construction (URL, headers, JSON body) is verified by +dedicated `test_http_client.py` and `test_async_http_client.py` tests, which is the +correct place for that concern anyway. + +--- + +## Technology: `pytest-asyncio` in auto mode + +Add `pytest-asyncio` to dev dependencies: + +```toml +# pyproject.toml +[project.optional-dependencies] +dev = [ + ... + "pytest-asyncio>=0.23", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" # every async def test_* runs in asyncio automatically +``` + +With `asyncio_mode = "auto"`: +- All `async def test_*` methods run in an event loop automatically — no decorator needed. +- Regular `def test_*` methods continue to work unchanged. +- No change to existing non-async tests. + +--- + +## The `UnifiedClient` wrapper + +**File: `tests/conftest.py`** + +`UnifiedClient` wraps either client variant and provides a consistent interface. +The mock abstraction is its most important role: + +```python +# tests/conftest.py + +import asyncio +import os +import platform +from contextlib import contextmanager +from importlib.metadata import version +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from descope.async_descope_client import AsyncDescopeClient +from descope.descope_client import DescopeClient + +# --- Constants reused across all test files --- + +DUMMY_PROJECT_ID = "P2CtzUhdqpIF2ys9gg7ms06UvtC4Pdummy" # 32-char valid format +DUMMY_MGMT_KEY = "key" +DEFAULT_BASE_URL = "http://127.0.0.1" + +PUBLIC_KEY_DICT = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", +} + +default_headers = { + "Content-Type": "application/json", + "x-descope-sdk-name": "python", + "x-descope-sdk-python-version": platform.python_version(), + "x-descope-sdk-version": version("descope"), +} + + +# --- Response factory --- + +def make_response(json_data=None, *, status=200, cookies=None): + """Build a mock httpx.Response for use as a mock return value.""" + mock = MagicMock() + mock.is_success = status < 400 + mock.status_code = status + mock.json.return_value = json_data or {} + mock_cookies = MagicMock() + mock_cookies.get = MagicMock(return_value=None) + if cookies: + mock_cookies.get = MagicMock(side_effect=lambda k, d=None: cookies.get(k, d)) + mock.cookies = mock_cookies + mock.headers = {} + mock.text = str(json_data or "") + return mock + + +# --- Unified client wrapper --- + +class UnifiedClient: + """ + Wraps DescopeClient or AsyncDescopeClient with a uniform interface for tests. + + Test bodies call self.invoke(...) and self.mock_post(...) without knowing which + mode they're running in. The wrapper translates to the right mock target and + call pattern for each mode. + """ + + def __init__(self, mode: str, raw): + self.mode = mode # "sync" | "async" + self._raw = raw + + def __getattr__(self, name): + return getattr(self._raw, name) + + # --- Execution --- + + async def invoke(self, maybe_coro): + """Uniformly execute a sync return value or an async coroutine.""" + if asyncio.iscoroutine(maybe_coro): + return await maybe_coro + return maybe_coro + + # --- Mock context managers --- + # Each yields the mock object so tests can optionally call assert_called_once/etc. + + @contextmanager + def mock_post(self, response): + """Mock the auth HTTP client's POST method.""" + with self._patch_ctx("post", response, "auth") as mock: + yield mock + + @contextmanager + def mock_get(self, response): + """Mock the auth HTTP client's GET method.""" + with self._patch_ctx("get", response, "auth") as mock: + yield mock + + @contextmanager + def mock_put(self, response): + with self._patch_ctx("put", response, "auth") as mock: + yield mock + + @contextmanager + def mock_delete(self, response): + with self._patch_ctx("delete", response, "auth") as mock: + yield mock + + @contextmanager + def mock_mgmt_post(self, response): + """Mock the management HTTP client's POST method.""" + with self._patch_ctx("post", response, "mgmt") as mock: + yield mock + + @contextmanager + def mock_mgmt_get(self, response): + with self._patch_ctx("get", response, "mgmt") as mock: + yield mock + + @contextmanager + def mock_mgmt_delete(self, response): + with self._patch_ctx("delete", response, "mgmt") as mock: + yield mock + + # --- Internals --- + + def _patch_ctx(self, http_method: str, response, target: str): + """ + Return a context manager that patches the right HTTP layer. + + Sync mode: patches httpx.{method} (module-level function) — same depth + as existing tests, preserving coverage of HTTPClient internals. + + Async mode: patches _async_client.{method} on the AsyncHTTPClient instance + — equivalent depth for the async path. + """ + if self.mode == "sync": + return patch(f"httpx.{http_method}", return_value=response) + else: + http_client = ( + self._raw._auth_http if target == "auth" else self._raw._mgmt_http + ) + return patch.object( + http_client._async_client, + http_method, + AsyncMock(return_value=response), + ) + + +# --- Fixtures --- + +@pytest.fixture(params=["sync", "async"]) +def descope_client(request): + """ + Parametrized fixture that yields a UnifiedClient wrapping either + DescopeClient (sync) or AsyncDescopeClient (async). + + Runs every consuming test twice: once per mode. + """ + os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL + if request.param == "sync": + raw = DescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT) + yield UnifiedClient("sync", raw) + else: + raw = AsyncDescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT) + yield UnifiedClient("async", raw) + + +@pytest.fixture(params=["sync", "async"]) +def mgmt_client(request): + """Same as descope_client but with a management key for management API tests.""" + os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL + if request.param == "sync": + raw = DescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT, False, DUMMY_MGMT_KEY) + yield UnifiedClient("sync", raw) + else: + raw = AsyncDescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT, False, DUMMY_MGMT_KEY) + yield UnifiedClient("async", raw) +``` + +--- + +## Structure of a unified test file + +Each existing test class is split into two logical sections: + +1. **Pure function tests** (`def`, no fixture) — test `_compose_*` static methods directly. + These are sync-only by nature (pure computation, no client needed). They stay exactly + as they are, just converted from `assertEqual/assertRaises` to `assert/pytest.raises`. + +2. **Behavioral tests** (`async def`, uses `descope_client` fixture) — parametrized + over sync and async. Test every public method: success path, validation failures, + and HTTP error handling. + +### Before → After example (`test_otp.py`) + +**Before (current pattern):** +```python +class TestOTP(common.DescopeTest): + def setUp(self): + self.client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + def test_compose_signin_url(self): + self.assertEqual(OTP._compose_signin_url(DeliveryMethod.EMAIL), "/v1/auth/otp/signin/email") + + def test_sign_up(self): + with patch("httpx.post") as mock_post: + my_mock_response = mock.Mock() + my_mock_response.is_success = True + my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} + mock_post.return_value = my_mock_response + self.assertEqual( + "t***@example.com", + self.client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user), + ) + mock_post.assert_called_with( + f"{DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", + headers={...}, + json={...}, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ... + ) +``` + +**After (unified pattern):** +```python +# --- Pure function tests (no client, no parametrization) --- + +def test_compose_signin_url(): + assert OTP._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signin/email" + assert OTP._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/otp/signin/sms" + +def test_compose_update_user_phone_body(): + result = OTP._compose_update_user_phone_body("dummy@dummy.com", "+11111111", False, True) + assert result == {"loginId": "dummy@dummy.com", "phone": "+11111111", + "addToLoginIDs": False, "onMergeUseExisting": True} + +# --- Behavioral tests (parametrized sync + async) --- + +class TestOTPSignUp: + async def test_invalid_email_raises(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke( + descope_client.otp.sign_up(DeliveryMethod.EMAIL, "not-an-email", {}) + ) + + async def test_sign_up_success(self, descope_client): + resp = make_response({"maskedEmail": "t***@example.com"}) + with descope_client.mock_post(resp): + result = await descope_client.invoke( + descope_client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", + {"email": "dummy@dummy.com"}) + ) + assert result == "t***@example.com" + + async def test_http_error_raises(self, descope_client): + resp = make_response({}, status=500) + with descope_client.mock_post(resp): + with pytest.raises(AuthException): + await descope_client.invoke( + descope_client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", + {"email": "dummy@dummy.com"}) + ) + + async def test_sign_up_with_signup_options(self, descope_client): + resp = make_response({"maskedEmail": "t***@example.com"}) + with descope_client.mock_post(resp) as mock: + result = await descope_client.invoke( + descope_client.otp.sign_up( + DeliveryMethod.EMAIL, "dummy@dummy.com", + {"email": "dummy@dummy.com"}, + SignUpOptions(template_options={"bla": "blue"}), + ) + ) + assert result == "t***@example.com" + assert mock.called # verify HTTP was called at all; body structure tested in test_http_client.py +``` + +The `assert_called_with(full_url, headers, json, ...)` checks from current tests are +**not** ported to the unified tests. They move to `test_http_client.py` and +`test_async_http_client.py` where they belong — testing HTTP mechanics, not business logic. +Any body-structure assertions that are purely about auth method logic (e.g., that +`templateOptions` is included when `SignUpOptions` has `template_options`) belong in the +unified test and are verified by checking `mock.call_args.kwargs["json"]` or similar. + +--- + +## The 10%: mode-specific tests (keep as-is or new) + +These tests are NOT unified and live in their own files. + +### Tests that stay sync-only (existing files, converted to plain pytest) + +**`tests/test_descope_client.py`** +- `DescopeClient.__init__` validation: missing project_id, skip_verify warning, `kwargs` rejection +- `validate_permissions`, `validate_session`, `refresh_session` (JWT validation — sync always) +- `get_last_response` in verbose mode + +**`tests/test_http_client.py`** (expanded from current) +- `HTTPClient.__init__`: SSL context setup, base_url resolution per region +- `HTTPClient.get/post/put/patch/delete`: verify `httpx.*` is called with exact URL, headers, JSON, timeout, verify +- Retry logic: responses with status 503/521 trigger retries with correct delays +- Rate limit exception on 429 +- `AuthException` on non-2xx +- `get_last_response` in verbose mode (thread-local) + +### New async-only tests + +**`tests/test_async_http_client.py`** +- `AsyncHTTPClient.__init__`: creates `httpx.AsyncClient` with correct verify/timeout +- `AsyncHTTPClient.get/post/put/patch/delete`: verify `_async_client.*` called with correct URL, headers, JSON +- Async retry logic: retries on same status codes, uses `asyncio.sleep` not `time.sleep` +- Rate limit and error raising (same as sync variant) +- `aclose()`: calls `_async_client.aclose()` +- `__aenter__`/`__aexit__`: context manager protocol + +**`tests/test_async_descope_client.py`** +- `AsyncDescopeClient.__init__`: same validation as `DescopeClient` +- `AsyncDescopeClient` as context manager: `async with ... as client:` pattern +- `aclose()`: both http clients are closed +- Verify async properties return `AsyncOTP`, `AsyncMGMT`, etc. + +--- + +## Migration strategy: from `unittest.TestCase` to plain pytest + +The existing tests use `unittest.TestCase` with `assertEqual`, `assertRaises`, `setUp`. +Migrate each file systematically: + +| Old | New | +|-----|-----| +| `class TestFoo(common.DescopeTest):` | `class TestFoo:` (plain pytest class) | +| `def setUp(self): self.client = ...` | Remove — client comes from `descope_client` fixture | +| `self.assertEqual(a, b)` | `assert a == b` | +| `self.assertRaises(Ex, fn, arg)` | `with pytest.raises(Ex): fn(arg)` | +| `self.assertIsNotNone(x)` | `assert x is not None` | +| `def test_*(self):` | `async def test_*(self, descope_client):` | +| `with patch("httpx.post") as mock:` | `with descope_client.mock_post(resp) as mock:` | +| `client.otp.sign_in(...)` | `await descope_client.invoke(descope_client.otp.sign_in(...))` | + +The `_compose_*` static method tests don't use a client at all. Convert them to +module-level `def test_*()` functions (no class, no fixture) — they need zero changes +beyond the `assertEqual` → `assert` syntax. + +--- + +## Coverage maintenance strategy + +With 98% minimum coverage enforced, here is how each file's coverage is maintained: + +| Code path | Covered by | +|-----------|-----------| +| `HTTPClient.get/post/put/patch/delete` | `test_http_client.py` (expanded, asserts on full httpx call) | +| `HTTPClient._execute_with_retry`, retry delays | `test_http_client.py` (mock httpx to return 503 repeatedly) | +| `AsyncHTTPClient.get/post/put/patch/delete` | `test_async_http_client.py` (patches `_async_client.*`) | +| `AsyncHTTPClient._async_execute_with_retry` | `test_async_http_client.py` | +| `AsyncHTTPClient.aclose`, `__aenter__/__aexit__` | `test_async_http_client.py` | +| Every auth method's business logic (sign_in, sign_up, verify, update) | Unified tests × 2 (both params) | +| Every management method | Unified tests × 2 | +| `DescopeClient.__init__` validation | `test_descope_client.py` (sync-only) | +| `AsyncDescopeClient.__init__` validation | `test_async_descope_client.py` (async-only) | +| `Auth.validate_session`, JWT validation | `test_auth.py` — these tests are sync-only and need no changes | +| `future_utils.py` | File is deleted; `test_future_utils.py` is also deleted | + +The current `test_future_utils.py` (added in Stage 0) is deleted along with the module it tests. + +The key insight: unified parametrized tests touch every auth/management method TWICE +(once per mode), so coverage for those paths is doubled. The HTTP client tests now need +to be more comprehensive to cover paths previously covered by the `assert_called_with` +checks in auth method tests. + +--- + +## File change summary + +| File | Action | +|------|--------| +| `tests/conftest.py` | **Create** — `UnifiedClient`, `make_response`, all fixtures | +| `tests/common.py` | Keep minimal — `DEFAULT_BASE_URL`, `default_headers` only (remove `DescopeTest` base class once migration complete) | +| `tests/test_future_utils.py` | **Delete** (module deleted) | +| `tests/test_http_client.py` | Expand: add full `assert_called_with` tests migrated from auth method tests | +| `tests/test_async_http_client.py` | **Create** — async transport tests | +| `tests/test_async_descope_client.py` | **Create** — lifecycle + init tests | +| `tests/test_descope_client.py` | Keep sync-only, convert to plain pytest | +| `tests/test_auth.py` | Keep sync-only (JWT validation is never async), convert syntax | +| `tests/test_otp.py` | Unify: static method tests stay plain; behavioral tests use `descope_client` fixture | +| `tests/test_totp.py` | Same | +| `tests/test_magiclink.py` | Same | +| `tests/test_enchantedlink.py` | Same | +| `tests/test_oauth.py` | Same | +| `tests/test_saml.py` | Same | +| `tests/test_sso.py` | Same | +| `tests/test_webauthn.py` | Same | +| `tests/test_password.py` | Same | +| `tests/management/conftest.py` | **Create** — `mgmt_client` fixture re-exported | +| `tests/management/test_user.py` | Unify using `mgmt_client` fixture | +| `tests/management/test_access_key.py` | Same | +| `tests/management/test_audit.py` | Same | +| `tests/management/test_authz.py` | Same | +| `tests/management/test_descoper.py` | Same | +| `tests/management/test_fga.py` | Same | +| `tests/management/test_flow.py` | Same | +| `tests/management/test_group.py` | Same | +| `tests/management/test_jwt.py` | Same | +| `tests/management/test_mgmtkey.py` | Same | +| `tests/management/test_outbound_application.py` | Same | +| `tests/management/test_permission.py` | Same | +| `tests/management/test_project.py` | Same | +| `tests/management/test_role.py` | Same | +| `tests/management/test_sso_application.py` | Same | +| `tests/management/test_sso_settings.py` | Same | +| `tests/management/test_tenant.py` | Same | + +--- + +## Edge cases where unified tests need special care + +### Cookie-based responses (`generate_jwt_response`) + +Some methods (OTP `verify_code`, TOTP `sign_in_code`, etc.) extract a refresh token +from `response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None)`. The `make_response` +helper supports this: + +```python +resp = make_response( + json_data={"sessionJwt": "...", "refreshJwt": "...", ...}, + cookies={REFRESH_SESSION_COOKIE_NAME: "refresh-token-value"}, +) +``` + +### Methods that call `get_last_response` / verbose mode + +These are sync-specific (thread-local). Test in `test_descope_client.py` / `test_http_client.py` only. + +### `EnchantedLink` polling + +`enchantedlink.get_session` polls and may block. Mock `httpx.get` to return immediately. +Unified test works as normal — the polling loop is still exercised. + +### Rate limit exceptions + +`make_response` can produce a 429 response. The `UnifiedClient.mock_post` machinery +handles it — both sync and async `HTTPClient` call `_raise_from_response` which raises +`RateLimitException`. The unified test: + +```python +async def test_rate_limit(self, descope_client): + resp = make_response( + {"errorCode": 429, "errorDescription": "Too many requests"}, + status=429, + ) + resp.headers = {"X-Rate-Limit-Retry-After-Seconds": "60"} + with descope_client.mock_post(resp): + with pytest.raises(RateLimitException): + await descope_client.invoke(descope_client.otp.sign_in(...)) +``` + +--- + +## Invariants + +1. **No test logic is duplicated** — each behavioral test exists once and runs twice (sync + async params) +2. **`_compose_*` static method tests remain sync-only** — they test pure functions, not clients +3. **HTTP transport mechanics are tested in dedicated files** — not in auth/management test files +4. **`asyncio_mode = "auto"`** means no decorator boilerplate on any test +5. **`make_response()` is the single factory** — no inline `MagicMock` construction in test bodies +6. **The `UnifiedClient` fixture teardown** is handled by pytest automatically (the fixture is a generator via `yield`) diff --git a/async-plan.md b/async-plan.md new file mode 100644 index 000000000..ca4251118 --- /dev/null +++ b/async-plan.md @@ -0,0 +1,443 @@ +# Async SDK Implementation Plan (v2) + +## Decision: Separate `AsyncDescopeClient` + async subclasses + +The chosen approach mirrors how Anthropic, OpenAI, and httpx structure dual-mode SDKs: +- `DescopeClient` — sync, unchanged, zero risk of regression +- `AsyncDescopeClient` — new, all `async def` methods, proper `Awaitable[T]` return types everywhere +- Shared static helpers (URL composition, body construction) inherited — not duplicated + +No code generation, no build step, no `Union[T, Awaitable[T]]` anywhere. + +--- + +## Stage 1 — `AsyncHTTPClient` + +**File: `descope/async_http_client.py`** + +`AsyncHTTPClient` inherits from `HTTPClient`. It gets all the shared setup logic +(`__init__`, SSL context, base_url resolution, management_key handling, verbose mode, +`_get_default_headers`, `_raise_from_response`, `_parse_retry_after`, +`_raise_rate_limit_exception`, `base_url_for_project_id`) for free. + +Its `__init__` calls `super().__init__()` then creates the `httpx.AsyncClient`: + +```python +class AsyncHTTPClient(HTTPClient): + def __init__(self, project_id, base_url=None, *, timeout_seconds, secure, + management_key=None, verbose=False) -> None: + super().__init__(project_id, base_url, timeout_seconds=timeout_seconds, + secure=secure, management_key=management_key, verbose=verbose) + self._async_client = httpx.AsyncClient( + verify=self.client_verify, + timeout=self.timeout_seconds, + ) +``` + +Then override each transport method with `async def`: + +```python + async def get(self, uri, *, params=None, allow_redirects=True, pswd=None) -> httpx.Response: + response = await self._async_execute_with_retry( + lambda: self._async_client.get( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + params=params, + follow_redirects=cast(bool, allow_redirects), + ) + ) + if self.verbose: + self._thread_local.last_response = DescopeResponse(response) + self._raise_from_response(response) + return response + + async def post(self, uri, *, body=None, params=None, pswd=None, base_url=None) -> httpx.Response: + ... # same pattern + + # put, patch, delete — same pattern + + async def _async_execute_with_retry(self, request_fn) -> httpx.Response: + response = await request_fn() + for delay in _RETRY_DELAYS_SECONDS: + if response.status_code not in _RETRY_STATUS_CODES: + break + await response.aclose() + await asyncio.sleep(delay) + response = await request_fn() + return response + + async def aclose(self) -> None: + await self._async_client.aclose() + + async def __aenter__(self) -> "AsyncHTTPClient": + return self + + async def __aexit__(self, *args) -> None: + await self.aclose() +``` + +**Type note**: Mypy will flag `async def get(...)` as an incompatible override of `HTTPClient.get` (sync → async return type change). Add `# type: ignore[override]` on each override. This is the only concession to type purity in the entire plan, and it's contained to `AsyncHTTPClient`. + +--- + +## Stage 2 — `Auth` stays sync + +`Auth` does CPU-bound JWT validation and a one-time public key fetch (using `HTTPClient`). +None of this needs to be async — JWT crypto is not I/O. `Auth` is unchanged. + +`AsyncDescopeClient` will create a regular (sync) `HTTPClient` solely to hand to `Auth` +for its internal public key fetch, then create an `AsyncHTTPClient` for all user-facing calls. +This is acceptable: the key fetch is a one-time initialization call, not per-request. + +--- + +## Stage 3 — `AsyncAuthBase` + +**File: `descope/_auth_base.py`** (add `AsyncAuthBase` alongside existing `AuthBase`) + +```python +class AsyncAuthBase: + """Base for async auth method classes.""" + + def __init__(self, auth: Auth, http: AsyncHTTPClient): + self._auth = auth # sync Auth — used only for JWT helpers (no I/O) + self._http = http # AsyncHTTPClient — used for all network calls +``` + +`self._auth` is used only for sync operations that don't touch the network: +`Auth.extract_masked_address()`, `Auth.validate_email()`, `Auth.compose_url()`, +`self._auth.generate_jwt_response()`, `self._auth.adjust_and_verify_delivery_method()`. +All of these are pure computation — no I/O — so keeping `Auth` sync is correct. + +--- + +## Stage 4 — Async authmethod classes + +**Pattern**: Each `Foo(AuthBase)` gets a sibling `AsyncFoo(AsyncAuthBase)` **in the same file**. +`AsyncFoo` inherits none of `Foo`'s instance methods (they're sync), but it *does* call +the same `@staticmethod _compose_*` methods which live on `Foo` and are referenced directly. + +```python +# In descope/authmethod/otp.py (after the existing OTP class) + +class AsyncOTP(AsyncAuthBase): + async def sign_in( + self, + method: DeliveryMethod, + login_id: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + validate_refresh_token_provided(login_options, refresh_token) + uri = OTP._compose_signin_url(method) # reuse sync static helper + body = OTP._compose_signin_body(login_id, login_options) + response = await self._http.post(uri, body=body, pswd=refresh_token) # only change + return Auth.extract_masked_address(response.json(), method) + + async def sign_up(self, ...) -> str: ... + async def sign_up_or_in(self, ...) -> str: ... + async def verify_code(self, ...) -> dict: ... + async def update_user_email(self, ...) -> str: ... + async def update_user_phone(self, ...) -> str: ... +``` + +The `@staticmethod _compose_*` methods are called as `OTP._compose_signin_url(method)` — +they don't need to be duplicated, just referenced from the sync class. + +**Authmethod files to add `Async*` to**: +- `otp.py` → `AsyncOTP` +- `totp.py` → `AsyncTOTP` +- `magiclink.py` → `AsyncMagicLink` +- `enchantedlink.py` → `AsyncEnchantedLink` +- `oauth.py` → `AsyncOAuth` +- `saml.py` → `AsyncSAML` +- `sso.py` → `AsyncSSO` +- `webauthn.py` → `AsyncWebAuthn` +- `password.py` → `AsyncPassword` + +Each follows the identical pattern: same method signatures, same validation, same body/URL +composition via `Foo._compose_*` statics, only `await self._http.*()` instead of `self._http.*()`. + +--- + +## Stage 5 — Async management classes + +**Pattern**: Same as authmethod — `AsyncFoo` added at the bottom of each management file. +Management classes extend `HTTPBase` (not `AuthBase`), so their async counterpart +takes only `AsyncHTTPClient`. + +`AsyncHTTPBase` (add to `_http_base.py`): +```python +class AsyncHTTPBase: + def __init__(self, http: AsyncHTTPClient): + self._http = http +``` + +Each management async class: +```python +# In descope/management/user.py (after User class) + +class AsyncUser(AsyncHTTPBase): + async def create(self, login_id: str, email=None, ...) -> dict: + # same validation as User.create + uri = MgmtV1.user_create_path + body = User._compose_create_body(login_id, email, ...) # reuse static + response = await self._http.post(uri, body=body) + return response.json() + + async def delete(self, login_id: str) -> None: + ... + + # all other methods follow same pattern +``` + +Where `User` has inline body construction (no static helper), inline it identically in `AsyncUser`. +The duplication per method is 2-3 lines of dict construction — acceptable. + +**Management files to add `Async*` to**: +- `user.py` → `AsyncUser` +- `access_key.py` → `AsyncAccessKey` +- `audit.py` → `AsyncAudit` +- `authz.py` → `AsyncAuthz` +- `descoper.py` → `AsyncDescoper` +- `fga.py` → `AsyncFGA` +- `flow.py` → `AsyncFlow` +- `group.py` → `AsyncGroup` +- `jwt.py` → `AsyncJWT` +- `management_key.py` → `AsyncManagementKey` +- `outbound_application.py` → `AsyncOutboundApplication`, `AsyncOutboundApplicationByToken` +- `permission.py` → `AsyncPermission` +- `project.py` → `AsyncProject` +- `role.py` → `AsyncRole` +- `sso_application.py` → `AsyncSSOApplication` +- `sso_settings.py` → `AsyncSSOSettings` +- `tenant.py` → `AsyncTenant` + +--- + +## Stage 6 — `AsyncMGMT` + +**File: `descope/async_mgmt.py`** + +Mirrors `MGMT` exactly, substituting async management classes: + +```python +class AsyncMGMT: + def __init__(self, http: AsyncHTTPClient, auth: Auth, fga_cache_url=None): + self._http = http + self._user = AsyncUser(http) + self._access_key = AsyncAccessKey(http) + self._audit = AsyncAudit(http) + self._authz = AsyncAuthz(http, fga_cache_url=fga_cache_url) + # ... all other async management classes + + def _ensure_management_key(self, property_name: str): + # identical to MGMT._ensure_management_key + if not self._http.management_key: + raise AuthException(...) + + @property + def user(self) -> AsyncUser: + self._ensure_management_key("user") + return self._user + + # ... all other properties, identical structure to MGMT +``` + +--- + +## Stage 7 — `AsyncDescopeClient` + +**File: `descope/async_descope_client.py`** + +```python +class AsyncDescopeClient: + def __init__( + self, + project_id: str, + public_key: dict | None = None, + skip_verify: bool = False, + management_key: str | None = None, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + jwt_validation_leeway: int = 5, + auth_management_key: str | None = None, + fga_cache_url: str | None = None, + *, + base_url: str | None = None, + verbose: bool = False, + ): + project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") + if not project_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "...") + + if skip_verify: + warnings.warn("⚠️ TLS verification disabled ...", UserWarning, stacklevel=2) + + # Auth uses a sync HTTPClient internally for one-time public key fetch. + # This is the only sync client created. It is not exposed to callers. + _auth_sync_http = HTTPClient( + project_id=project_id, + base_url=base_url, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + verbose=verbose, + ) + self._auth = Auth(project_id, public_key, jwt_validation_leeway, + http_client=_auth_sync_http) + + # All user-facing calls go through these async clients. + self._auth_http = AsyncHTTPClient( + project_id=project_id, + base_url=_auth_sync_http.base_url, # reuse resolved URL + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), + verbose=verbose, + ) + self._mgmt_http = AsyncHTTPClient( + project_id=project_id, + base_url=_auth_sync_http.base_url, + timeout_seconds=timeout_seconds, + secure=not skip_verify, + management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), + verbose=verbose, + ) + + self._otp = AsyncOTP(self._auth, self._auth_http) + self._totp = AsyncTOTP(self._auth, self._auth_http) + self._magiclink = AsyncMagicLink(self._auth, self._auth_http) + self._enchantedlink = AsyncEnchantedLink(self._auth, self._auth_http) + self._oauth = AsyncOAuth(self._auth, self._auth_http) + self._saml = AsyncSAML(self._auth, self._auth_http) + self._sso = AsyncSSO(self._auth, self._auth_http) + self._webauthn = AsyncWebAuthn(self._auth, self._auth_http) + self._password = AsyncPassword(self._auth, self._auth_http) + + self._mgmt = AsyncMGMT(self._mgmt_http, self._auth, fga_cache_url=fga_cache_url) + + # Context manager support + async def __aenter__(self) -> "AsyncDescopeClient": + return self + + async def __aexit__(self, *args) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._auth_http.aclose() + await self._mgmt_http.aclose() + + # Properties — identical names to DescopeClient for API parity + @property + def otp(self) -> AsyncOTP: return self._otp + @property + def totp(self) -> AsyncTOTP: return self._totp + @property + def magiclink(self) -> AsyncMagicLink: return self._magiclink + @property + def enchantedlink(self) -> AsyncEnchantedLink: return self._enchantedlink + @property + def oauth(self) -> AsyncOAuth: return self._oauth + @property + def saml(self) -> AsyncSAML: return self._saml # deprecated + @property + def sso(self) -> AsyncSSO: return self._sso + @property + def webauthn(self) -> AsyncWebAuthn: return self._webauthn + @property + def password(self) -> AsyncPassword: return self._password + @property + def mgmt(self) -> AsyncMGMT: return self._mgmt + + # JWT validation helpers — remain sync (no I/O) + def validate_session(self, session_token: str) -> dict: + return self._auth.validate_session_request(session_token) + + def refresh_session(self, refresh_token: str) -> dict: + return self._auth.refresh_session(refresh_token) + + def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: + return self._auth.validate_permissions(jwt_response, permissions) + + # ... all other validate_*/get_matched_* methods from DescopeClient, unchanged + + def get_last_response(self) -> DescopeResponse | None: + # Returns from the auth http client (most recent call) + return self._auth_http.get_last_response() +``` + +--- + +## Stage 8 — Public API + +**`descope/__init__.py`**: Add `AsyncDescopeClient` to imports and `__all__`. + +```python +from descope.async_descope_client import AsyncDescopeClient +``` + +**Usage examples** (for README/docs, not in this plan): +```python +# As context manager (recommended): +async with AsyncDescopeClient(project_id="P...") as client: + masked = await client.otp.sign_in(DeliveryMethod.EMAIL, "user@example.com") + +# Standalone with explicit close: +client = AsyncDescopeClient(project_id="P...") +try: + masked = await client.otp.sign_in(DeliveryMethod.EMAIL, "user@example.com") +finally: + await client.aclose() +``` + +--- + +## File change summary + +| File | Action | +|------|--------| +| `descope/async_http_client.py` | **Create** — `AsyncHTTPClient(HTTPClient)` | +| `descope/_auth_base.py` | Add `AsyncAuthBase` class | +| `descope/_http_base.py` | Add `AsyncHTTPBase` class | +| `descope/async_descope_client.py` | **Create** — `AsyncDescopeClient` | +| `descope/async_mgmt.py` | **Create** — `AsyncMGMT` | +| `descope/authmethod/otp.py` | Add `AsyncOTP` class | +| `descope/authmethod/totp.py` | Add `AsyncTOTP` class | +| `descope/authmethod/magiclink.py` | Add `AsyncMagicLink` class | +| `descope/authmethod/enchantedlink.py` | Add `AsyncEnchantedLink` class | +| `descope/authmethod/oauth.py` | Add `AsyncOAuth` class | +| `descope/authmethod/saml.py` | Add `AsyncSAML` class | +| `descope/authmethod/sso.py` | Add `AsyncSSO` class | +| `descope/authmethod/webauthn.py` | Add `AsyncWebAuthn` class | +| `descope/authmethod/password.py` | Add `AsyncPassword` class | +| `descope/management/user.py` | Add `AsyncUser` class | +| `descope/management/access_key.py` | Add `AsyncAccessKey` class | +| `descope/management/audit.py` | Add `AsyncAudit` class | +| `descope/management/authz.py` | Add `AsyncAuthz` class | +| `descope/management/descoper.py` | Add `AsyncDescoper` class | +| `descope/management/fga.py` | Add `AsyncFGA` class | +| `descope/management/flow.py` | Add `AsyncFlow` class | +| `descope/management/group.py` | Add `AsyncGroup` class | +| `descope/management/jwt.py` | Add `AsyncJWT` class | +| `descope/management/management_key.py` | Add `AsyncManagementKey` class | +| `descope/management/outbound_application.py` | Add `AsyncOutboundApplication`, `AsyncOutboundApplicationByToken` | +| `descope/management/permission.py` | Add `AsyncPermission` class | +| `descope/management/project.py` | Add `AsyncProject` class | +| `descope/management/role.py` | Add `AsyncRole` class | +| `descope/management/sso_application.py` | Add `AsyncSSOApplication` class | +| `descope/management/sso_settings.py` | Add `AsyncSSOSettings` class | +| `descope/management/tenant.py` | Add `AsyncTenant` class | +| `descope/__init__.py` | Add `AsyncDescopeClient` export | + +--- + +## Key invariants to maintain throughout + +1. **`DescopeClient` and all sync classes are unchanged in behavior** — no regressions +2. **Every `AsyncFoo` method has an identical signature to its sync counterpart**, differing only in `async def` and `await` +3. **No `Union[T, Awaitable[T]]` anywhere** — sync returns `T`, async returns `Awaitable[T]` +4. **`Auth` is never async** — it holds public keys in memory after init, JWT validation is CPU-only +5. **`AsyncHTTPClient` is the only place that touches `httpx.AsyncClient`** +6. **`AsyncDescopeClient` owns the `AsyncHTTPClient` lifecycle** — callers use context manager or explicit `aclose()` diff --git a/descope/__init__.py b/descope/__init__.py index 9b64e921d..1dc61e47b 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,4 +1,4 @@ -from descope.async_descope_client import AsyncDescopeClient +from descope.descope_client_async import DescopeClientAsync from descope.common import ( COOKIE_DATA_NAME, REFRESH_SESSION_COOKIE_NAME, @@ -65,7 +65,7 @@ "DeliveryMethod", "LoginOptions", "SignUpOptions", - "AsyncDescopeClient", + "DescopeClientAsync", "DescopeClient", "API_RATE_LIMIT_RETRY_AFTER_HEADER", "ERROR_TYPE_API_RATE_LIMIT", diff --git a/descope/_auth_base.py b/descope/_auth_base.py index 965bd3560..915799059 100644 --- a/descope/_auth_base.py +++ b/descope/_auth_base.py @@ -6,7 +6,7 @@ from descope.auth import Auth if TYPE_CHECKING: - from descope.async_http_client import AsyncHTTPClient + from descope.http_client_async import HTTPClientAsync class AuthBase: @@ -25,6 +25,6 @@ class AsyncAuthBase: and an AsyncHTTPClient for all network calls. """ - def __init__(self, auth: Auth, http: AsyncHTTPClient): + def __init__(self, auth: Auth, http: HTTPClientAsync): self._auth = auth self._http = http diff --git a/descope/_client_base.py b/descope/_client_base.py index 7d45b022f..cf0f87a06 100644 --- a/descope/_client_base.py +++ b/descope/_client_base.py @@ -19,7 +19,7 @@ class DescopeClientBase: """ - Shared base for DescopeClient and AsyncDescopeClient. + Shared base for DescopeClient and DescopeClientAsync. Handles: - project_id validation and skip_verify warning @@ -72,7 +72,7 @@ def __init__( self._auth = Auth(project_id, public_key, jwt_validation_leeway, http_client=_auth_http) # ------------------------------------------------------------------------- - # Argument-validation guards — reused by both DescopeClient and AsyncDescopeClient + # Argument-validation guards — reused by both DescopeClient and DescopeClientAsync # ------------------------------------------------------------------------- @staticmethod diff --git a/descope/_http_base.py b/descope/_http_base.py index bbf201c6c..0324f96b7 100644 --- a/descope/_http_base.py +++ b/descope/_http_base.py @@ -5,7 +5,7 @@ from descope.http_client import HTTPClient if TYPE_CHECKING: - from descope.async_http_client import AsyncHTTPClient + from descope.http_client_async import HTTPClientAsync class HTTPBase: @@ -18,5 +18,5 @@ def __init__(self, http_client: HTTPClient): class AsyncHTTPBase: """Base for async management classes.""" - def __init__(self, http_client: AsyncHTTPClient): + def __init__(self, http_client: HTTPClientAsync): self._http = http_client diff --git a/descope/_http_client_base.py b/descope/_http_client_base.py new file mode 100644 index 000000000..5243eea73 --- /dev/null +++ b/descope/_http_client_base.py @@ -0,0 +1,256 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +import os +import platform +import ssl +from http import HTTPStatus +from importlib.metadata import version + +import certifi +import httpx + +from descope.common import ( + DEFAULT_BASE_URL, + DEFAULT_DOMAIN, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_URL_PREFIX, +) +from descope.exceptions import ( + API_RATE_LIMIT_RETRY_AFTER_HEADER, + ERROR_TYPE_API_RATE_LIMIT, + ERROR_TYPE_INVALID_ARGUMENT, + ERROR_TYPE_SERVER_ERROR, + AuthException, + RateLimitException, +) + + +def sdk_version(): + return version("descope") + + +# HTTP status codes that should trigger automatic retries +_RETRY_STATUS_CODES = {503, 521, 522, 524, 530} +# Delays in seconds between retries: first retry after 100ms, subsequent retries after 5s +_RETRY_DELAYS_SECONDS = [0.1, 5.0, 5.0] + +_default_headers = { + "Content-Type": "application/json", + "x-descope-sdk-name": "python", + "x-descope-sdk-python-version": platform.python_version(), + "x-descope-sdk-version": sdk_version(), +} + + +class DescopeResponse: + """ + Wrapper around httpx.Response that provides dict-like access to JSON data + while preserving access to HTTP metadata (headers, status_code, etc.). + + This allows backward compatibility (acting like a dict) while exposing + HTTP metadata like cf-ray headers for debugging. + """ + + def __init__(self, response: httpx.Response): + self.raw = response + self._json_data = None + + def json(self): + """Get the parsed JSON response, cached after first access.""" + if self._json_data is None: + self._json_data = self.raw.json() + return self._json_data + + # Dict-like interface for backward compatibility + def __getitem__(self, key): + return self.json()[key] + + def __contains__(self, key): + return key in self.json() + + def keys(self): + return self.json().keys() + + def values(self): + return self.json().values() + + def items(self): + return self.json().items() + + def get(self, key, default=None): + return self.json().get(key, default) + + def __str__(self): + return str(self.json()) + + def __repr__(self): + return f"DescopeResponse({repr(self.json())})" + + def __bool__(self): + return bool(self.json()) + + def __len__(self): + return len(self.json()) + + def __eq__(self, other): + if isinstance(other, DescopeResponse): + return self.json() == other.json() + return self.json() == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __iter__(self): + return iter(self.json()) + + # HTTP metadata properties + @property + def headers(self): + """Access response headers (e.g., response.headers.get('cf-ray')).""" + return self.raw.headers + + @property + def status_code(self): + """HTTP status code.""" + return self.raw.status_code + + @property + def cookies(self): + """Response cookies.""" + return self.raw.cookies + + @property + def text(self): + """Raw response text.""" + return self.raw.text + + @property + def content(self): + """Raw response content (bytes).""" + return self.raw.content + + @property + def url(self): + """Request URL.""" + return self.raw.url + + @property + def ok(self): + """True if status code indicates success (2xx).""" + return self.raw.is_success + + +class HTTPClientBase: + """Shared, I/O-free base for HTTP client classes. + + Holds only validation guards, SSL setup, header composition, and response + parsing — no network I/O, no ``__init__`` transport. The two concrete + subclasses add the network layer: + + - ``HTTPClient(HTTPClientBase)`` — sync, uses httpx module-level functions + - ``HTTPClientAsync(HTTPClientBase)`` — async, uses ``httpx.AsyncClient`` + """ + + def __init__( + self, + project_id: str, + base_url: str | None = None, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + secure: bool = True, + management_key: str | None = None, + verbose: bool = False, + ) -> None: + if not project_id: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + ( + "Project ID is required to initialize HTTP client" + "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." + ), + ) + + env_base = os.getenv("DESCOPE_BASE_URI") + self.base_url = base_url or env_base or self.base_url_for_project_id(project_id) + + self.project_id = project_id + self.timeout_seconds = timeout_seconds + self.secure = secure + self.management_key = management_key + self.verbose = verbose + # Populated by the license handshake when a management key is configured. + # Sent in the x-descope-license header so Cloudflare can apply the right + # rate limit bucket per customer tier. + self.rate_limit_tier: str | None = None + + self.client_verify: bool | ssl.SSLContext = False + if secure: + ssl_ctx = ssl.create_default_context( + cafile=os.environ.get("SSL_CERT_FILE", certifi.where()), + capath=os.environ.get("SSL_CERT_DIR"), + ) + if os.environ.get("REQUESTS_CA_BUNDLE"): + ssl_ctx.load_verify_locations(cafile=os.environ.get("REQUESTS_CA_BUNDLE")) + self.client_verify = ssl_ctx + + @staticmethod + def base_url_for_project_id(project_id: str) -> str: + if len(project_id) >= 32: + region = project_id[1:5] + return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) + return DEFAULT_BASE_URL + + def get_default_headers(self, pswd: str | None = None) -> dict: + return self._get_default_headers(pswd) + + def _parse_retry_after(self, headers) -> int: + try: + return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) + except (ValueError, TypeError): + return 0 + + def _raise_rate_limit_exception(self, response): + try: + resp = response.json() + raise RateLimitException( + resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), + ERROR_TYPE_API_RATE_LIMIT, + resp.get("errorDescription", ""), + resp.get("errorMessage", ""), + rate_limit_parameters={API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after(response.headers)}, + ) + except RateLimitException: + raise + except Exception: + raise RateLimitException( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + error_type=ERROR_TYPE_API_RATE_LIMIT, + error_message=ERROR_TYPE_API_RATE_LIMIT, + error_description=ERROR_TYPE_API_RATE_LIMIT, + ) + + def _raise_from_response(self, response): + if response.is_success: + return + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + self._raise_rate_limit_exception(response) + raise AuthException( + response.status_code, + ERROR_TYPE_SERVER_ERROR, + response.text, + ) + + def _get_default_headers(self, pswd: str | None = None): + headers = _default_headers.copy() + headers["x-descope-project-id"] = self.project_id + bearer = self.project_id + if pswd: + bearer = f"{self.project_id}:{pswd}" + if self.management_key: + bearer = f"{bearer}:{self.management_key}" + headers["Authorization"] = f"Bearer {bearer}" + if self.rate_limit_tier: + headers["x-descope-license"] = self.rate_limit_tier + return headers diff --git a/descope/authmethod/async_totp.py b/descope/authmethod/totp_async.py similarity index 98% rename from descope/authmethod/async_totp.py rename to descope/authmethod/totp_async.py index d4acc5850..b1655d379 100644 --- a/descope/authmethod/async_totp.py +++ b/descope/authmethod/totp_async.py @@ -12,7 +12,7 @@ ) -class AsyncTOTP(TOTPBase, AsyncAuthBase): +class TOTPAsync(TOTPBase, AsyncAuthBase): """Async TOTP auth-method. All network calls are coroutines; validation is sync (no I/O).""" async def sign_up(self, login_id: str, user: Optional[dict] = None) -> dict: diff --git a/descope/async_descope_client.py b/descope/descope_client_async.py similarity index 94% rename from descope/async_descope_client.py rename to descope/descope_client_async.py index 7df627f07..1877fe7a7 100644 --- a/descope/async_descope_client.py +++ b/descope/descope_client_async.py @@ -4,8 +4,8 @@ from typing import Iterable from descope._client_base import DescopeClientBase -from descope.async_http_client import AsyncHTTPClient -from descope.authmethod.async_totp import AsyncTOTP +from descope.http_client_async import HTTPClientAsync +from descope.authmethod.totp_async import TOTPAsync from descope.common import ( DEFAULT_TIMEOUT_SECONDS, REFRESH_SESSION_COOKIE_NAME, @@ -18,7 +18,7 @@ ) -class AsyncDescopeClient(DescopeClientBase): +class DescopeClientAsync(DescopeClientBase): """ Async counterpart of DescopeClient. @@ -28,11 +28,11 @@ class AsyncDescopeClient(DescopeClientBase): they perform no I/O. Usage (recommended — context manager): - async with AsyncDescopeClient(project_id="P...") as client: + async with DescopeClientAsync(project_id="P...") as client: jwt = await client.refresh_session(refresh_token) Usage (explicit close): - client = AsyncDescopeClient(project_id="P...") + client = DescopeClientAsync(project_id="P...") try: jwt = await client.refresh_session(refresh_token) finally: @@ -65,7 +65,7 @@ def __init__( ) resolved_base_url = self._auth.http_client.base_url - self._auth_http = AsyncHTTPClient( + self._auth_http = HTTPClientAsync( project_id=self._auth.project_id, base_url=resolved_base_url, timeout_seconds=timeout_seconds, @@ -73,7 +73,7 @@ def __init__( management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), verbose=verbose, ) - self._mgmt_http = AsyncHTTPClient( + self._mgmt_http = HTTPClientAsync( project_id=self._auth.project_id, base_url=resolved_base_url, timeout_seconds=timeout_seconds, @@ -83,13 +83,13 @@ def __init__( ) self._fga_cache_url = fga_cache_url - self._totp = AsyncTOTP(self._auth, self._auth_http) + self._totp = TOTPAsync(self._auth, self._auth_http) if self._mgmt_http.management_key: self._fetch_rate_limit_tier(self._mgmt_http) @property - def totp(self) -> AsyncTOTP: + def totp(self) -> TOTPAsync: return self._totp # ------------------------------------------------------------------------- @@ -101,7 +101,7 @@ async def aclose(self) -> None: await self._auth_http.aclose() await self._mgmt_http.aclose() - async def __aenter__(self) -> AsyncDescopeClient: + async def __aenter__(self) -> DescopeClientAsync: return self async def __aexit__(self, *args) -> None: diff --git a/descope/http_client.py b/descope/http_client.py index 7562db188..2f3d1a6a1 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -1,149 +1,21 @@ from __future__ import annotations -import os -import platform -import ssl import threading import time -from http import HTTPStatus -from importlib.metadata import version from typing import cast -import certifi import httpx -from descope.common import ( - DEFAULT_BASE_URL, - DEFAULT_DOMAIN, +from descope._http_client_base import ( DEFAULT_TIMEOUT_SECONDS, - DEFAULT_URL_PREFIX, + DescopeResponse, + HTTPClientBase, + _RETRY_DELAYS_SECONDS, + _RETRY_STATUS_CODES, ) -from descope.exceptions import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - ERROR_TYPE_INVALID_ARGUMENT, - ERROR_TYPE_SERVER_ERROR, - AuthException, - RateLimitException, -) - - -def sdk_version(): - return version("descope") - - -# HTTP status codes that should trigger automatic retries -_RETRY_STATUS_CODES = {503, 521, 522, 524, 530} -# Delays in seconds between retries: first retry after 100ms, subsequent retries after 5s -_RETRY_DELAYS_SECONDS = [0.1, 5.0, 5.0] - -_default_headers = { - "Content-Type": "application/json", - "x-descope-sdk-name": "python", - "x-descope-sdk-python-version": platform.python_version(), - "x-descope-sdk-version": sdk_version(), -} - - -class DescopeResponse: - """ - Wrapper around httpx.Response that provides dict-like access to JSON data - while preserving access to HTTP metadata (headers, status_code, etc.). - - This allows backward compatibility (acting like a dict) while exposing - HTTP metadata like cf-ray headers for debugging. - """ - - def __init__(self, response: httpx.Response): - self.raw = response - self._json_data = None - - def json(self): - """Get the parsed JSON response, cached after first access.""" - if self._json_data is None: - self._json_data = self.raw.json() - return self._json_data - - # Dict-like interface for backward compatibility - def __getitem__(self, key): - return self.json()[key] - - def __contains__(self, key): - return key in self.json() - - def keys(self): - return self.json().keys() - - def values(self): - return self.json().values() - - def items(self): - return self.json().items() - - def get(self, key, default=None): - return self.json().get(key, default) - - def __str__(self): - return str(self.json()) - - def __repr__(self): - return f"DescopeResponse({repr(self.json())})" - - def __bool__(self): - return bool(self.json()) - - def __len__(self): - return len(self.json()) - - def __eq__(self, other): - if isinstance(other, DescopeResponse): - return self.json() == other.json() - return self.json() == other - - def __ne__(self, other): - return not self.__eq__(other) - def __iter__(self): - return iter(self.json()) - # HTTP metadata properties - @property - def headers(self): - """Access response headers (e.g., response.headers.get('cf-ray')).""" - return self.raw.headers - - @property - def status_code(self): - """HTTP status code.""" - return self.raw.status_code - - @property - def cookies(self): - """Response cookies.""" - return self.raw.cookies - - @property - def text(self): - """Raw response text.""" - return self.raw.text - - @property - def content(self): - """Raw response content (bytes).""" - return self.raw.content - - @property - def url(self): - """Request URL.""" - return self.raw.url - - @property - def ok(self): - """True if status code indicates success (2xx).""" - return self.raw.is_success - - -class HTTPClient: +class HTTPClient(HTTPClientBase): def __init__( self, project_id: str, @@ -154,41 +26,15 @@ def __init__( management_key: str | None = None, verbose: bool = False, ) -> None: - if not project_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - ( - "Project ID is required to initialize HTTP client" - "Set environment variable DESCOPE_PROJECT_ID or pass your Project ID to the init function." - ), - ) - - # Prefer explicitly provided base_url, then env var, then computed default - env_base = os.getenv("DESCOPE_BASE_URI") - self.base_url = base_url or env_base or self.base_url_for_project_id(project_id) - - self.project_id = project_id - self.timeout_seconds = timeout_seconds - self.secure = secure - self.management_key = management_key - self.verbose = verbose + super().__init__( + project_id, + base_url, + timeout_seconds=timeout_seconds, + secure=secure, + management_key=management_key, + verbose=verbose, + ) self._thread_local = threading.local() - # Populated by the license handshake when a management key is configured. - # Sent in the x-descope-license header so Cloudflare can apply the right - # rate limit bucket per customer tier. - self.rate_limit_tier: str | None = None - - # Setup SSL verification for httpx (backwards compatibility with requests) - self.client_verify: bool | ssl.SSLContext = False - if secure: - ssl_ctx = ssl.create_default_context( - cafile=os.environ.get("SSL_CERT_FILE", certifi.where()), - capath=os.environ.get("SSL_CERT_DIR"), - ) - if os.environ.get("REQUESTS_CA_BUNDLE"): - ssl_ctx.load_verify_locations(cafile=os.environ.get("REQUESTS_CA_BUNDLE")) - self.client_verify = ssl_ctx # ------------- public API ------------- def get( @@ -331,9 +177,6 @@ def get_last_response(self) -> DescopeResponse | None: """ return getattr(self._thread_local, "last_response", None) - def get_default_headers(self, pswd: str | None = None) -> dict: - return self._get_default_headers(pswd) - # ------------- helpers ------------- def _execute_with_retry(self, request_fn) -> httpx.Response: """Execute request_fn and retry on retryable status codes. @@ -350,60 +193,3 @@ def _execute_with_retry(self, request_fn) -> httpx.Response: time.sleep(delay) response = request_fn() return response - - @staticmethod - def base_url_for_project_id(project_id: str) -> str: - if len(project_id) >= 32: - region = project_id[1:5] - return ".".join([DEFAULT_URL_PREFIX, region, DEFAULT_DOMAIN]) - return DEFAULT_BASE_URL - - def _parse_retry_after(self, headers) -> int: - try: - return int(headers.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)) - except (ValueError, TypeError): - return 0 - - def _raise_rate_limit_exception(self, response): - try: - resp = response.json() - raise RateLimitException( - resp.get("errorCode", HTTPStatus.TOO_MANY_REQUESTS), - ERROR_TYPE_API_RATE_LIMIT, - resp.get("errorDescription", ""), - resp.get("errorMessage", ""), - rate_limit_parameters={API_RATE_LIMIT_RETRY_AFTER_HEADER: self._parse_retry_after(response.headers)}, - ) - except RateLimitException: - raise - except Exception: - raise RateLimitException( - status_code=HTTPStatus.TOO_MANY_REQUESTS, - error_type=ERROR_TYPE_API_RATE_LIMIT, - error_message=ERROR_TYPE_API_RATE_LIMIT, - error_description=ERROR_TYPE_API_RATE_LIMIT, - ) - - def _raise_from_response(self, response): - if response.is_success: - return - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - self._raise_rate_limit_exception(response) - raise AuthException( - response.status_code, - ERROR_TYPE_SERVER_ERROR, - response.text, - ) - - def _get_default_headers(self, pswd: str | None = None): - headers = _default_headers.copy() - headers["x-descope-project-id"] = self.project_id - bearer = self.project_id - if pswd: - bearer = f"{self.project_id}:{pswd}" - if self.management_key: - bearer = f"{bearer}:{self.management_key}" - headers["Authorization"] = f"Bearer {bearer}" - if self.rate_limit_tier: - headers["x-descope-license"] = self.rate_limit_tier - return headers diff --git a/descope/async_http_client.py b/descope/http_client_async.py similarity index 92% rename from descope/async_http_client.py rename to descope/http_client_async.py index ee153d0e2..6702dc1ae 100644 --- a/descope/async_http_client.py +++ b/descope/http_client_async.py @@ -6,22 +6,23 @@ import httpx -from descope.http_client import ( +from descope._http_client_base import ( + DEFAULT_TIMEOUT_SECONDS, + DescopeResponse, + HTTPClientBase, _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES, - DescopeResponse, - HTTPClient, ) -class AsyncHTTPClient(HTTPClient): +class HTTPClientAsync(HTTPClientBase): def __init__( self, project_id: str, base_url: str | None = None, *, - timeout_seconds: float, - secure: bool, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + secure: bool = True, management_key: str | None = None, verbose: bool = False, ) -> None: @@ -41,7 +42,7 @@ def __init__( "descope_async_last_response", default=None ) - async def get( # type: ignore[override] + async def get( self, uri: str, *, @@ -62,7 +63,7 @@ async def get( # type: ignore[override] self._raise_from_response(response) return response - async def post( # type: ignore[override] + async def post( self, uri: str, *, @@ -85,7 +86,7 @@ async def post( # type: ignore[override] self._raise_from_response(response) return response - async def put( # type: ignore[override] + async def put( self, uri: str, *, @@ -105,7 +106,7 @@ async def put( # type: ignore[override] self._raise_from_response(response) return response - async def patch( # type: ignore[override] + async def patch( self, uri: str, *, @@ -127,7 +128,7 @@ async def patch( # type: ignore[override] self._raise_from_response(response) return response - async def delete( # type: ignore[override] + async def delete( self, uri: str, *, @@ -169,7 +170,7 @@ async def _async_execute_with_retry(self, request_fn) -> httpx.Response: async def aclose(self) -> None: await self._async_client.aclose() - async def __aenter__(self) -> AsyncHTTPClient: + async def __aenter__(self) -> HTTPClientAsync: return self async def __aexit__(self, *args) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 5cca39b4b..4a74de007 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pytest -from descope.async_descope_client import AsyncDescopeClient +from descope.descope_client_async import DescopeClientAsync from descope.descope_client import DescopeClient from tests.common import DEFAULT_BASE_URL @@ -56,7 +56,7 @@ def make_response(json_data=None, *, status=200, cookies=None): class UnifiedClient: """ - Wraps DescopeClient or AsyncDescopeClient with a uniform interface so test + Wraps DescopeClient or DescopeClientAsync with a uniform interface so test bodies can run unchanged against both variants. - ``invoke(maybe_coro)`` — awaits async calls, passes through sync values. @@ -131,7 +131,7 @@ def make(self, *args, **kwargs) -> UnifiedClient: """Construct a (Async)DescopeClient and wrap it in UnifiedClient.""" if self.mode == "sync": return UnifiedClient("sync", DescopeClient(*args, **kwargs)) - client = AsyncDescopeClient(*args, **kwargs) + client = DescopeClientAsync(*args, **kwargs) self._async_clients.append(client) return UnifiedClient("async", client) @@ -145,7 +145,7 @@ def make(self, *args, **kwargs) -> UnifiedClient: async def descope_client(request): """ Parametrized fixture — yields a UnifiedClient wrapping DescopeClient (sync) - or AsyncDescopeClient (async). Each consuming test runs twice. + or DescopeClientAsync (async). Each consuming test runs twice. """ # Save and restore DESCOPE_BASE_URI so it doesn't leak into other tests. _prev = os.environ.get("DESCOPE_BASE_URI") @@ -154,7 +154,7 @@ async def descope_client(request): if request.param == "sync": yield UnifiedClient("sync", DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT)) else: - raw = AsyncDescopeClient(PROJECT_ID, PUBLIC_KEY_DICT) + raw = DescopeClientAsync(PROJECT_ID, PUBLIC_KEY_DICT) yield UnifiedClient("async", raw) await raw.aclose() # release the underlying httpx.AsyncClient cleanly finally: diff --git a/tests/test_descope_client_parity.py b/tests/test_descope_client_parity.py index 02c1d1ea1..8027a237e 100644 --- a/tests/test_descope_client_parity.py +++ b/tests/test_descope_client_parity.py @@ -2,10 +2,10 @@ Parity port of test_descope_client.py using the unified sync/async fixture infrastructure. Structure mirrors the original: one class, one test method per feature. Each method -runs twice — once for the sync DescopeClient and once for AsyncDescopeClient — via +runs twice — once for the sync DescopeClient and once for DescopeClientAsync — via pytest's parametrised ``descope_client`` / ``client_factory`` fixtures from conftest. -Tests that exercise surfaces not yet ported to AsyncDescopeClient (mgmt, otp, oauth…) +Tests that exercise surfaces not yet ported to DescopeClientAsync (mgmt, otp, oauth…) call ``pytest.skip()`` in async mode so the original assertions are preserved verbatim. """ @@ -156,7 +156,7 @@ async def test_project_id_from_env_without_env(self, client_factory): async def test_mgmt(self, descope_client): if descope_client.mode != "sync": - pytest.skip("mgmt not available on AsyncDescopeClient") + pytest.skip("mgmt not available on DescopeClientAsync") # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set with pytest.raises(AuthException): @@ -784,7 +784,7 @@ async def test_select_tenant(self, client_factory): async def test_auth_management_key_with_functions(self, client_factory): if client_factory.mode != "sync": - pytest.skip("otp not available on AsyncDescopeClient") + pytest.skip("otp not available on DescopeClientAsync") auth_mgmt_key = "test-auth-mgmt-key" @@ -883,7 +883,7 @@ async def test_auth_management_key_with_functions(self, client_factory): async def test_auth_management_key_with_refresh_token(self, client_factory): if client_factory.mode != "sync": - pytest.skip("otp not available on AsyncDescopeClient") + pytest.skip("otp not available on DescopeClientAsync") auth_mgmt_key = "test-auth-mgmt-key" client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) @@ -985,7 +985,7 @@ async def test_verbose_mode_enabled(self, client_factory): async def test_verbose_mode_captures_mgmt_response(self, client_factory): if client_factory.mode != "sync": - pytest.skip("mgmt not available on AsyncDescopeClient") + pytest.skip("mgmt not available on DescopeClientAsync") mock_response = mock.Mock() mock_response.is_success = True diff --git a/tests/test_http_client.py b/tests/test_http_client.py index f9f90851a..0062c1626 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -802,7 +802,7 @@ def test_default_cert_source_is_certifi(self): for key in ("SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE"): os.environ.pop(key, None) - with patch("descope.http_client.ssl.create_default_context") as mock_ctx_factory: + with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: mock_ssl_ctx = Mock() mock_ctx_factory.return_value = mock_ssl_ctx @@ -819,7 +819,7 @@ def test_ssl_cert_file_env_overrides_certifi(self): os.environ.pop("SSL_CERT_DIR", None) os.environ.pop("REQUESTS_CA_BUNDLE", None) - with patch("descope.http_client.ssl.create_default_context") as mock_ctx_factory: + with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: mock_ctx_factory.return_value = Mock() HTTPClient(project_id="test123", secure=True) @@ -837,7 +837,7 @@ def test_ssl_cert_dir_env_passed_as_capath(self): os.environ.pop("SSL_CERT_FILE", None) os.environ.pop("REQUESTS_CA_BUNDLE", None) - with patch("descope.http_client.ssl.create_default_context") as mock_ctx_factory: + with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: mock_ctx_factory.return_value = Mock() HTTPClient(project_id="test123", secure=True) @@ -853,7 +853,7 @@ def test_requests_ca_bundle_env_loaded_into_context(self): os.environ.pop("SSL_CERT_FILE", None) os.environ.pop("SSL_CERT_DIR", None) - with patch("descope.http_client.ssl.create_default_context") as mock_ctx_factory: + with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: mock_ssl_ctx = Mock() mock_ctx_factory.return_value = mock_ssl_ctx @@ -866,7 +866,7 @@ def test_no_extra_load_when_requests_ca_bundle_unset(self): with patch.dict("os.environ", {}, clear=False): os.environ.pop("REQUESTS_CA_BUNDLE", None) - with patch("descope.http_client.ssl.create_default_context") as mock_ctx_factory: + with patch("descope._http_client_base.ssl.create_default_context") as mock_ctx_factory: mock_ssl_ctx = Mock() mock_ctx_factory.return_value = mock_ssl_ctx diff --git a/tests/test_async_http_client.py b/tests/test_http_client_async.py similarity index 91% rename from tests/test_async_http_client.py rename to tests/test_http_client_async.py index 0d4f14fc9..2c7a720ae 100644 --- a/tests/test_async_http_client.py +++ b/tests/test_http_client_async.py @@ -4,9 +4,9 @@ import pytest -from descope.async_http_client import AsyncHTTPClient from descope.exceptions import AuthException, RateLimitException from descope.http_client import _RETRY_DELAYS_SECONDS, _RETRY_STATUS_CODES +from descope.http_client_async import HTTPClientAsync from tests.testutils import SSLMatcher # --------------------------------------------------------------------------- @@ -23,8 +23,8 @@ def make_async_client(*, secure=True, verbose=False, project_id="test123", base_ base_url is passed explicitly so tests are never affected by the DESCOPE_BASE_URI env var that unittest-based tests leave set. """ - with patch("descope.async_http_client.httpx.AsyncClient"): - return AsyncHTTPClient( + with patch("descope.http_client_async.httpx.AsyncClient"): + return HTTPClientAsync( project_id=project_id, base_url=base_url, timeout_seconds=60, @@ -52,22 +52,22 @@ def make_resp(*, status=200, json_data=None, headers=None, text=""): class TestAsyncHTTPClientInit: def test_secure_passes_ssl_context(self): - with patch("descope.async_http_client.httpx.AsyncClient") as mock_cls: - AsyncHTTPClient(project_id="test123", timeout_seconds=30, secure=True) + with patch("descope.http_client_async.httpx.AsyncClient") as mock_cls: + HTTPClientAsync(project_id="test123", timeout_seconds=30, secure=True) _, kwargs = mock_cls.call_args assert kwargs["verify"] == SSLMatcher() assert kwargs["timeout"] == 30 def test_insecure_passes_false(self): - with patch("descope.async_http_client.httpx.AsyncClient") as mock_cls: - AsyncHTTPClient(project_id="test123", timeout_seconds=10, secure=False) + with patch("descope.http_client_async.httpx.AsyncClient") as mock_cls: + HTTPClientAsync(project_id="test123", timeout_seconds=10, secure=False) _, kwargs = mock_cls.call_args assert kwargs["verify"] == SSLMatcher(insecure=True) def test_empty_project_id_raises(self): - with patch("descope.async_http_client.httpx.AsyncClient"): + with patch("descope.http_client_async.httpx.AsyncClient"): with pytest.raises(AuthException) as exc_info: - AsyncHTTPClient(project_id="", timeout_seconds=30, secure=True) + HTTPClientAsync(project_id="", timeout_seconds=30, secure=True) assert exc_info.value.status_code == 400 @@ -166,7 +166,7 @@ async def test_retries_on_retryable_codes(self): err = make_resp(status=status_code) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: client._async_client.get = AsyncMock(side_effect=[err, ok]) resp = await client.get("/x") @@ -179,7 +179,7 @@ async def test_retries_to_exhaustion_raises(self): client = make_async_client() err = make_resp(status=503, text="Unavailable") - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: client._async_client.get = AsyncMock(return_value=err) with pytest.raises(AuthException): await client.get("/x") @@ -196,7 +196,7 @@ async def test_retry_delay_sequence(self): async def fake_sleep(delay): sleep_calls.append(delay) - with patch("descope.async_http_client.asyncio.sleep", fake_sleep): + with patch("descope.http_client_async.asyncio.sleep", fake_sleep): client._async_client.get = AsyncMock(return_value=err) with pytest.raises(AuthException): await client.get("/x") @@ -208,7 +208,7 @@ async def test_no_retry_on_non_retryable_codes(self): client = make_async_client() err = make_resp(status=status_code, text=f"Error {status_code}") - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: client._async_client.get = AsyncMock(return_value=err) with pytest.raises(AuthException): await client.get("/x") @@ -222,7 +222,7 @@ async def test_prior_response_closed_before_retry(self): err2 = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) await client.get("/x") @@ -233,7 +233,7 @@ async def test_prior_response_closed_before_retry(self): async def test_success_on_first_attempt_no_retry(self): client = make_async_client() ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()) as mock_sleep: + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()) as mock_sleep: client._async_client.get = AsyncMock(return_value=ok) await client.get("/x") assert client._async_client.get.await_count == 1 @@ -244,7 +244,7 @@ async def test_retry_succeeds_on_third_attempt(self): err1 = make_resp(status=503) err2 = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.get = AsyncMock(side_effect=[err1, err2, ok]) resp = await client.get("/x") assert resp.status_code == 200 @@ -254,7 +254,7 @@ async def test_retry_works_for_post(self): client = make_async_client() err = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.post = AsyncMock(side_effect=[err, ok]) resp = await client.post("/x", body={}) assert resp.status_code == 200 @@ -264,7 +264,7 @@ async def test_retry_works_for_put(self): client = make_async_client() err = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.put = AsyncMock(side_effect=[err, ok]) resp = await client.put("/x", body={}) assert resp.status_code == 200 @@ -274,7 +274,7 @@ async def test_retry_works_for_patch(self): client = make_async_client() err = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.patch = AsyncMock(side_effect=[err, ok]) resp = await client.patch("/x", body={}) assert resp.status_code == 200 @@ -284,7 +284,7 @@ async def test_retry_works_for_delete(self): client = make_async_client() err = make_resp(status=503) ok = make_resp(status=200) - with patch("descope.async_http_client.asyncio.sleep", AsyncMock()): + with patch("descope.http_client_async.asyncio.sleep", AsyncMock()): client._async_client.delete = AsyncMock(side_effect=[err, ok]) resp = await client.delete("/x") assert resp.status_code == 200 @@ -410,9 +410,9 @@ async def test_aclose_delegates_to_async_client(self): client._async_client.aclose.assert_awaited_once() async def test_context_manager_yields_client_and_closes(self): - with patch("descope.async_http_client.httpx.AsyncClient"): - async with AsyncHTTPClient(project_id="test123", timeout_seconds=60, secure=True) as c: - assert isinstance(c, AsyncHTTPClient) + with patch("descope.http_client_async.httpx.AsyncClient"): + async with HTTPClientAsync(project_id="test123", timeout_seconds=60, secure=True) as c: + assert isinstance(c, HTTPClientAsync) c._async_client.aclose = AsyncMock() c._async_client.aclose.assert_awaited_once() @@ -426,8 +426,8 @@ async def test_context_manager_yields_client_and_closes(self): class TestAsyncHTTPClientHeaders: async def test_management_key_in_authorization_header(self): """auth_management_key is baked into the Authorization header on every verb call.""" - with patch("descope.async_http_client.httpx.AsyncClient"): - client = AsyncHTTPClient( + with patch("descope.http_client_async.httpx.AsyncClient"): + client = HTTPClientAsync( project_id="proj123", timeout_seconds=60, secure=True, diff --git a/tests/test_totp_parity.py b/tests/test_totp_parity.py index 2cd5a049d..686fd9d16 100644 --- a/tests/test_totp_parity.py +++ b/tests/test_totp_parity.py @@ -2,7 +2,7 @@ Parity port of test_totp.py using the unified sync/async fixture infrastructure. Structure mirrors the original: one class, one test method per operation. -Each method runs twice — once for sync DescopeClient and once for AsyncDescopeClient — +Each method runs twice — once for sync DescopeClient and once for DescopeClientAsync — via pytest's parametrised ``client_factory`` fixture from conftest. Payload assertions (assert_http_called) are included where the original had them: From 14b59a88baaf9029ffc70a082ba227bbe9b9f52a Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:20:47 +0300 Subject: [PATCH 07/17] ruff --- descope/__init__.py | 2 +- descope/descope_client_async.py | 2 +- descope/http_client.py | 4 ++-- descope/http_client_async.py | 4 ++-- tests/conftest.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/descope/__init__.py b/descope/__init__.py index 1dc61e47b..9fdc22880 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -1,4 +1,3 @@ -from descope.descope_client_async import DescopeClientAsync from descope.common import ( COOKIE_DATA_NAME, REFRESH_SESSION_COOKIE_NAME, @@ -11,6 +10,7 @@ SignUpOptions, ) from descope.descope_client import DescopeClient +from descope.descope_client_async import DescopeClientAsync from descope.exceptions import ( API_RATE_LIMIT_RETRY_AFTER_HEADER, ERROR_TYPE_API_RATE_LIMIT, diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 1877fe7a7..4187d90a3 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -4,7 +4,6 @@ from typing import Iterable from descope._client_base import DescopeClientBase -from descope.http_client_async import HTTPClientAsync from descope.authmethod.totp_async import TOTPAsync from descope.common import ( DEFAULT_TIMEOUT_SECONDS, @@ -16,6 +15,7 @@ ERROR_TYPE_INVALID_TOKEN, AuthException, ) +from descope.http_client_async import HTTPClientAsync class DescopeClientAsync(DescopeClientBase): diff --git a/descope/http_client.py b/descope/http_client.py index 2f3d1a6a1..badcf0c2a 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -7,11 +7,11 @@ import httpx from descope._http_client_base import ( + _RETRY_DELAYS_SECONDS, + _RETRY_STATUS_CODES, DEFAULT_TIMEOUT_SECONDS, DescopeResponse, HTTPClientBase, - _RETRY_DELAYS_SECONDS, - _RETRY_STATUS_CODES, ) diff --git a/descope/http_client_async.py b/descope/http_client_async.py index 6702dc1ae..85836dc6f 100644 --- a/descope/http_client_async.py +++ b/descope/http_client_async.py @@ -7,11 +7,11 @@ import httpx from descope._http_client_base import ( + _RETRY_DELAYS_SECONDS, + _RETRY_STATUS_CODES, DEFAULT_TIMEOUT_SECONDS, DescopeResponse, HTTPClientBase, - _RETRY_DELAYS_SECONDS, - _RETRY_STATUS_CODES, ) diff --git a/tests/conftest.py b/tests/conftest.py index 4a74de007..056ee2015 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,8 +7,8 @@ import pytest -from descope.descope_client_async import DescopeClientAsync from descope.descope_client import DescopeClient +from descope.descope_client_async import DescopeClientAsync from tests.common import DEFAULT_BASE_URL # --------------------------------------------------------------------------- From 43b2cc0ad59e550aeac594b5d743d776f44f0707 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:21:56 +0300 Subject: [PATCH 08/17] remove plan files and revert vscode settings --- .vscode/settings.json | 10 +- async-plan-tests.md | 555 ------------------------------------------ async-plan.md | 443 --------------------------------- 3 files changed, 4 insertions(+), 1004 deletions(-) delete mode 100644 async-plan-tests.md delete mode 100644 async-plan.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 9c3c5bec9..a6e85ca89 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,8 @@ { "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": ["tests"], + "python.testing.pytestArgs": [ + "tests" + ], "ruff.importStrategy": "fromEnvironment", "mypy-type-checker.importStrategy": "fromEnvironment", "[python]": { @@ -11,9 +13,5 @@ "source.organizeImports.ruff": "explicit" } }, - "workbench.colorCustomizations": { - /* do not change please... */ - }, - "python-envs.defaultEnvManager": "ms-python.python:venv", - "python-envs.defaultPackageManager": "ms-python.python:pip" + "workbench.colorCustomizations": { /* do not change please... */} } diff --git a/async-plan-tests.md b/async-plan-tests.md deleted file mode 100644 index 7ac971309..000000000 --- a/async-plan-tests.md +++ /dev/null @@ -1,555 +0,0 @@ -# Test Refactoring Plan (v2) — Unified Sync/Async Testing - -## Goal - -Run 90% of existing tests against both `DescopeClient` (sync) and `AsyncDescopeClient` -(async) with zero duplication of test logic. The remaining 10% covers mode-specific -concerns: client initialization, HTTP transport mechanics, and lifecycle (aclose, context manager). -The 98% coverage requirement must be maintained throughout. - ---- - -## Core problem & solution - -### The fundamental challenge - -You cannot `await` inside a sync function, so a test body written for a sync client -can't directly call an async client. Two building blocks solve this completely: - -**1. `invoke()` — run sync or async calls uniformly from `async def` tests** - -```python -async def invoke(self, maybe_coro): - if asyncio.iscoroutine(maybe_coro): - return await maybe_coro - return maybe_coro -``` - -- Sync client: `client.otp.sign_in(...)` executes immediately, returns a value. `invoke(value)` wraps and returns it. -- Async client: `client.otp.sign_in(...)` returns an unawaited coroutine. `invoke(coro)` awaits it. - -For exception tests: sync raises during argument evaluation (before `invoke` is called); -async raises when the coroutine runs inside `invoke`. Both are caught by the surrounding -`pytest.raises` context manager. No special casing needed. - -**2. `UnifiedClient` — abstracts sync/async construction, client access, and mock setup** - -Wraps either `DescopeClient` or `AsyncDescopeClient`, providing a uniform interface -to the test body so tests never branch on mode. - -### Test depth trade-off - -Current tests mock at `httpx.post` (module level) and assert on the full HTTP call -(URL, headers, JSON body). This pattern tests two things at once: -1. The auth method builds the right URI path and request body -2. `HTTPClient.post` correctly assembles the final `httpx.post` call - -Unifying these two layers across sync and async is possible but requires different -`assert_called_with` signatures (sync includes `verify=SSLMatcher(), timeout=...`; -async doesn't, since those are on the client level). - -**Decision**: Unified tests verify behavioral correctness (right return value, right -exceptions). Exact HTTP request construction (URL, headers, JSON body) is verified by -dedicated `test_http_client.py` and `test_async_http_client.py` tests, which is the -correct place for that concern anyway. - ---- - -## Technology: `pytest-asyncio` in auto mode - -Add `pytest-asyncio` to dev dependencies: - -```toml -# pyproject.toml -[project.optional-dependencies] -dev = [ - ... - "pytest-asyncio>=0.23", -] - -[tool.pytest.ini_options] -asyncio_mode = "auto" # every async def test_* runs in asyncio automatically -``` - -With `asyncio_mode = "auto"`: -- All `async def test_*` methods run in an event loop automatically — no decorator needed. -- Regular `def test_*` methods continue to work unchanged. -- No change to existing non-async tests. - ---- - -## The `UnifiedClient` wrapper - -**File: `tests/conftest.py`** - -`UnifiedClient` wraps either client variant and provides a consistent interface. -The mock abstraction is its most important role: - -```python -# tests/conftest.py - -import asyncio -import os -import platform -from contextlib import contextmanager -from importlib.metadata import version -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from descope.async_descope_client import AsyncDescopeClient -from descope.descope_client import DescopeClient - -# --- Constants reused across all test files --- - -DUMMY_PROJECT_ID = "P2CtzUhdqpIF2ys9gg7ms06UvtC4Pdummy" # 32-char valid format -DUMMY_MGMT_KEY = "key" -DEFAULT_BASE_URL = "http://127.0.0.1" - -PUBLIC_KEY_DICT = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", -} - -default_headers = { - "Content-Type": "application/json", - "x-descope-sdk-name": "python", - "x-descope-sdk-python-version": platform.python_version(), - "x-descope-sdk-version": version("descope"), -} - - -# --- Response factory --- - -def make_response(json_data=None, *, status=200, cookies=None): - """Build a mock httpx.Response for use as a mock return value.""" - mock = MagicMock() - mock.is_success = status < 400 - mock.status_code = status - mock.json.return_value = json_data or {} - mock_cookies = MagicMock() - mock_cookies.get = MagicMock(return_value=None) - if cookies: - mock_cookies.get = MagicMock(side_effect=lambda k, d=None: cookies.get(k, d)) - mock.cookies = mock_cookies - mock.headers = {} - mock.text = str(json_data or "") - return mock - - -# --- Unified client wrapper --- - -class UnifiedClient: - """ - Wraps DescopeClient or AsyncDescopeClient with a uniform interface for tests. - - Test bodies call self.invoke(...) and self.mock_post(...) without knowing which - mode they're running in. The wrapper translates to the right mock target and - call pattern for each mode. - """ - - def __init__(self, mode: str, raw): - self.mode = mode # "sync" | "async" - self._raw = raw - - def __getattr__(self, name): - return getattr(self._raw, name) - - # --- Execution --- - - async def invoke(self, maybe_coro): - """Uniformly execute a sync return value or an async coroutine.""" - if asyncio.iscoroutine(maybe_coro): - return await maybe_coro - return maybe_coro - - # --- Mock context managers --- - # Each yields the mock object so tests can optionally call assert_called_once/etc. - - @contextmanager - def mock_post(self, response): - """Mock the auth HTTP client's POST method.""" - with self._patch_ctx("post", response, "auth") as mock: - yield mock - - @contextmanager - def mock_get(self, response): - """Mock the auth HTTP client's GET method.""" - with self._patch_ctx("get", response, "auth") as mock: - yield mock - - @contextmanager - def mock_put(self, response): - with self._patch_ctx("put", response, "auth") as mock: - yield mock - - @contextmanager - def mock_delete(self, response): - with self._patch_ctx("delete", response, "auth") as mock: - yield mock - - @contextmanager - def mock_mgmt_post(self, response): - """Mock the management HTTP client's POST method.""" - with self._patch_ctx("post", response, "mgmt") as mock: - yield mock - - @contextmanager - def mock_mgmt_get(self, response): - with self._patch_ctx("get", response, "mgmt") as mock: - yield mock - - @contextmanager - def mock_mgmt_delete(self, response): - with self._patch_ctx("delete", response, "mgmt") as mock: - yield mock - - # --- Internals --- - - def _patch_ctx(self, http_method: str, response, target: str): - """ - Return a context manager that patches the right HTTP layer. - - Sync mode: patches httpx.{method} (module-level function) — same depth - as existing tests, preserving coverage of HTTPClient internals. - - Async mode: patches _async_client.{method} on the AsyncHTTPClient instance - — equivalent depth for the async path. - """ - if self.mode == "sync": - return patch(f"httpx.{http_method}", return_value=response) - else: - http_client = ( - self._raw._auth_http if target == "auth" else self._raw._mgmt_http - ) - return patch.object( - http_client._async_client, - http_method, - AsyncMock(return_value=response), - ) - - -# --- Fixtures --- - -@pytest.fixture(params=["sync", "async"]) -def descope_client(request): - """ - Parametrized fixture that yields a UnifiedClient wrapping either - DescopeClient (sync) or AsyncDescopeClient (async). - - Runs every consuming test twice: once per mode. - """ - os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL - if request.param == "sync": - raw = DescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT) - yield UnifiedClient("sync", raw) - else: - raw = AsyncDescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT) - yield UnifiedClient("async", raw) - - -@pytest.fixture(params=["sync", "async"]) -def mgmt_client(request): - """Same as descope_client but with a management key for management API tests.""" - os.environ["DESCOPE_BASE_URI"] = DEFAULT_BASE_URL - if request.param == "sync": - raw = DescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT, False, DUMMY_MGMT_KEY) - yield UnifiedClient("sync", raw) - else: - raw = AsyncDescopeClient(DUMMY_PROJECT_ID, PUBLIC_KEY_DICT, False, DUMMY_MGMT_KEY) - yield UnifiedClient("async", raw) -``` - ---- - -## Structure of a unified test file - -Each existing test class is split into two logical sections: - -1. **Pure function tests** (`def`, no fixture) — test `_compose_*` static methods directly. - These are sync-only by nature (pure computation, no client needed). They stay exactly - as they are, just converted from `assertEqual/assertRaises` to `assert/pytest.raises`. - -2. **Behavioral tests** (`async def`, uses `descope_client` fixture) — parametrized - over sync and async. Test every public method: success path, validation failures, - and HTTP error handling. - -### Before → After example (`test_otp.py`) - -**Before (current pattern):** -```python -class TestOTP(common.DescopeTest): - def setUp(self): - self.client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - def test_compose_signin_url(self): - self.assertEqual(OTP._compose_signin_url(DeliveryMethod.EMAIL), "/v1/auth/otp/signin/email") - - def test_sign_up(self): - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - self.client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user), - ) - mock_post.assert_called_with( - f"{DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={...}, - json={...}, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ... - ) -``` - -**After (unified pattern):** -```python -# --- Pure function tests (no client, no parametrization) --- - -def test_compose_signin_url(): - assert OTP._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signin/email" - assert OTP._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/otp/signin/sms" - -def test_compose_update_user_phone_body(): - result = OTP._compose_update_user_phone_body("dummy@dummy.com", "+11111111", False, True) - assert result == {"loginId": "dummy@dummy.com", "phone": "+11111111", - "addToLoginIDs": False, "onMergeUseExisting": True} - -# --- Behavioral tests (parametrized sync + async) --- - -class TestOTPSignUp: - async def test_invalid_email_raises(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke( - descope_client.otp.sign_up(DeliveryMethod.EMAIL, "not-an-email", {}) - ) - - async def test_sign_up_success(self, descope_client): - resp = make_response({"maskedEmail": "t***@example.com"}) - with descope_client.mock_post(resp): - result = await descope_client.invoke( - descope_client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", - {"email": "dummy@dummy.com"}) - ) - assert result == "t***@example.com" - - async def test_http_error_raises(self, descope_client): - resp = make_response({}, status=500) - with descope_client.mock_post(resp): - with pytest.raises(AuthException): - await descope_client.invoke( - descope_client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", - {"email": "dummy@dummy.com"}) - ) - - async def test_sign_up_with_signup_options(self, descope_client): - resp = make_response({"maskedEmail": "t***@example.com"}) - with descope_client.mock_post(resp) as mock: - result = await descope_client.invoke( - descope_client.otp.sign_up( - DeliveryMethod.EMAIL, "dummy@dummy.com", - {"email": "dummy@dummy.com"}, - SignUpOptions(template_options={"bla": "blue"}), - ) - ) - assert result == "t***@example.com" - assert mock.called # verify HTTP was called at all; body structure tested in test_http_client.py -``` - -The `assert_called_with(full_url, headers, json, ...)` checks from current tests are -**not** ported to the unified tests. They move to `test_http_client.py` and -`test_async_http_client.py` where they belong — testing HTTP mechanics, not business logic. -Any body-structure assertions that are purely about auth method logic (e.g., that -`templateOptions` is included when `SignUpOptions` has `template_options`) belong in the -unified test and are verified by checking `mock.call_args.kwargs["json"]` or similar. - ---- - -## The 10%: mode-specific tests (keep as-is or new) - -These tests are NOT unified and live in their own files. - -### Tests that stay sync-only (existing files, converted to plain pytest) - -**`tests/test_descope_client.py`** -- `DescopeClient.__init__` validation: missing project_id, skip_verify warning, `kwargs` rejection -- `validate_permissions`, `validate_session`, `refresh_session` (JWT validation — sync always) -- `get_last_response` in verbose mode - -**`tests/test_http_client.py`** (expanded from current) -- `HTTPClient.__init__`: SSL context setup, base_url resolution per region -- `HTTPClient.get/post/put/patch/delete`: verify `httpx.*` is called with exact URL, headers, JSON, timeout, verify -- Retry logic: responses with status 503/521 trigger retries with correct delays -- Rate limit exception on 429 -- `AuthException` on non-2xx -- `get_last_response` in verbose mode (thread-local) - -### New async-only tests - -**`tests/test_async_http_client.py`** -- `AsyncHTTPClient.__init__`: creates `httpx.AsyncClient` with correct verify/timeout -- `AsyncHTTPClient.get/post/put/patch/delete`: verify `_async_client.*` called with correct URL, headers, JSON -- Async retry logic: retries on same status codes, uses `asyncio.sleep` not `time.sleep` -- Rate limit and error raising (same as sync variant) -- `aclose()`: calls `_async_client.aclose()` -- `__aenter__`/`__aexit__`: context manager protocol - -**`tests/test_async_descope_client.py`** -- `AsyncDescopeClient.__init__`: same validation as `DescopeClient` -- `AsyncDescopeClient` as context manager: `async with ... as client:` pattern -- `aclose()`: both http clients are closed -- Verify async properties return `AsyncOTP`, `AsyncMGMT`, etc. - ---- - -## Migration strategy: from `unittest.TestCase` to plain pytest - -The existing tests use `unittest.TestCase` with `assertEqual`, `assertRaises`, `setUp`. -Migrate each file systematically: - -| Old | New | -|-----|-----| -| `class TestFoo(common.DescopeTest):` | `class TestFoo:` (plain pytest class) | -| `def setUp(self): self.client = ...` | Remove — client comes from `descope_client` fixture | -| `self.assertEqual(a, b)` | `assert a == b` | -| `self.assertRaises(Ex, fn, arg)` | `with pytest.raises(Ex): fn(arg)` | -| `self.assertIsNotNone(x)` | `assert x is not None` | -| `def test_*(self):` | `async def test_*(self, descope_client):` | -| `with patch("httpx.post") as mock:` | `with descope_client.mock_post(resp) as mock:` | -| `client.otp.sign_in(...)` | `await descope_client.invoke(descope_client.otp.sign_in(...))` | - -The `_compose_*` static method tests don't use a client at all. Convert them to -module-level `def test_*()` functions (no class, no fixture) — they need zero changes -beyond the `assertEqual` → `assert` syntax. - ---- - -## Coverage maintenance strategy - -With 98% minimum coverage enforced, here is how each file's coverage is maintained: - -| Code path | Covered by | -|-----------|-----------| -| `HTTPClient.get/post/put/patch/delete` | `test_http_client.py` (expanded, asserts on full httpx call) | -| `HTTPClient._execute_with_retry`, retry delays | `test_http_client.py` (mock httpx to return 503 repeatedly) | -| `AsyncHTTPClient.get/post/put/patch/delete` | `test_async_http_client.py` (patches `_async_client.*`) | -| `AsyncHTTPClient._async_execute_with_retry` | `test_async_http_client.py` | -| `AsyncHTTPClient.aclose`, `__aenter__/__aexit__` | `test_async_http_client.py` | -| Every auth method's business logic (sign_in, sign_up, verify, update) | Unified tests × 2 (both params) | -| Every management method | Unified tests × 2 | -| `DescopeClient.__init__` validation | `test_descope_client.py` (sync-only) | -| `AsyncDescopeClient.__init__` validation | `test_async_descope_client.py` (async-only) | -| `Auth.validate_session`, JWT validation | `test_auth.py` — these tests are sync-only and need no changes | -| `future_utils.py` | File is deleted; `test_future_utils.py` is also deleted | - -The current `test_future_utils.py` (added in Stage 0) is deleted along with the module it tests. - -The key insight: unified parametrized tests touch every auth/management method TWICE -(once per mode), so coverage for those paths is doubled. The HTTP client tests now need -to be more comprehensive to cover paths previously covered by the `assert_called_with` -checks in auth method tests. - ---- - -## File change summary - -| File | Action | -|------|--------| -| `tests/conftest.py` | **Create** — `UnifiedClient`, `make_response`, all fixtures | -| `tests/common.py` | Keep minimal — `DEFAULT_BASE_URL`, `default_headers` only (remove `DescopeTest` base class once migration complete) | -| `tests/test_future_utils.py` | **Delete** (module deleted) | -| `tests/test_http_client.py` | Expand: add full `assert_called_with` tests migrated from auth method tests | -| `tests/test_async_http_client.py` | **Create** — async transport tests | -| `tests/test_async_descope_client.py` | **Create** — lifecycle + init tests | -| `tests/test_descope_client.py` | Keep sync-only, convert to plain pytest | -| `tests/test_auth.py` | Keep sync-only (JWT validation is never async), convert syntax | -| `tests/test_otp.py` | Unify: static method tests stay plain; behavioral tests use `descope_client` fixture | -| `tests/test_totp.py` | Same | -| `tests/test_magiclink.py` | Same | -| `tests/test_enchantedlink.py` | Same | -| `tests/test_oauth.py` | Same | -| `tests/test_saml.py` | Same | -| `tests/test_sso.py` | Same | -| `tests/test_webauthn.py` | Same | -| `tests/test_password.py` | Same | -| `tests/management/conftest.py` | **Create** — `mgmt_client` fixture re-exported | -| `tests/management/test_user.py` | Unify using `mgmt_client` fixture | -| `tests/management/test_access_key.py` | Same | -| `tests/management/test_audit.py` | Same | -| `tests/management/test_authz.py` | Same | -| `tests/management/test_descoper.py` | Same | -| `tests/management/test_fga.py` | Same | -| `tests/management/test_flow.py` | Same | -| `tests/management/test_group.py` | Same | -| `tests/management/test_jwt.py` | Same | -| `tests/management/test_mgmtkey.py` | Same | -| `tests/management/test_outbound_application.py` | Same | -| `tests/management/test_permission.py` | Same | -| `tests/management/test_project.py` | Same | -| `tests/management/test_role.py` | Same | -| `tests/management/test_sso_application.py` | Same | -| `tests/management/test_sso_settings.py` | Same | -| `tests/management/test_tenant.py` | Same | - ---- - -## Edge cases where unified tests need special care - -### Cookie-based responses (`generate_jwt_response`) - -Some methods (OTP `verify_code`, TOTP `sign_in_code`, etc.) extract a refresh token -from `response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None)`. The `make_response` -helper supports this: - -```python -resp = make_response( - json_data={"sessionJwt": "...", "refreshJwt": "...", ...}, - cookies={REFRESH_SESSION_COOKIE_NAME: "refresh-token-value"}, -) -``` - -### Methods that call `get_last_response` / verbose mode - -These are sync-specific (thread-local). Test in `test_descope_client.py` / `test_http_client.py` only. - -### `EnchantedLink` polling - -`enchantedlink.get_session` polls and may block. Mock `httpx.get` to return immediately. -Unified test works as normal — the polling loop is still exercised. - -### Rate limit exceptions - -`make_response` can produce a 429 response. The `UnifiedClient.mock_post` machinery -handles it — both sync and async `HTTPClient` call `_raise_from_response` which raises -`RateLimitException`. The unified test: - -```python -async def test_rate_limit(self, descope_client): - resp = make_response( - {"errorCode": 429, "errorDescription": "Too many requests"}, - status=429, - ) - resp.headers = {"X-Rate-Limit-Retry-After-Seconds": "60"} - with descope_client.mock_post(resp): - with pytest.raises(RateLimitException): - await descope_client.invoke(descope_client.otp.sign_in(...)) -``` - ---- - -## Invariants - -1. **No test logic is duplicated** — each behavioral test exists once and runs twice (sync + async params) -2. **`_compose_*` static method tests remain sync-only** — they test pure functions, not clients -3. **HTTP transport mechanics are tested in dedicated files** — not in auth/management test files -4. **`asyncio_mode = "auto"`** means no decorator boilerplate on any test -5. **`make_response()` is the single factory** — no inline `MagicMock` construction in test bodies -6. **The `UnifiedClient` fixture teardown** is handled by pytest automatically (the fixture is a generator via `yield`) diff --git a/async-plan.md b/async-plan.md deleted file mode 100644 index ca4251118..000000000 --- a/async-plan.md +++ /dev/null @@ -1,443 +0,0 @@ -# Async SDK Implementation Plan (v2) - -## Decision: Separate `AsyncDescopeClient` + async subclasses - -The chosen approach mirrors how Anthropic, OpenAI, and httpx structure dual-mode SDKs: -- `DescopeClient` — sync, unchanged, zero risk of regression -- `AsyncDescopeClient` — new, all `async def` methods, proper `Awaitable[T]` return types everywhere -- Shared static helpers (URL composition, body construction) inherited — not duplicated - -No code generation, no build step, no `Union[T, Awaitable[T]]` anywhere. - ---- - -## Stage 1 — `AsyncHTTPClient` - -**File: `descope/async_http_client.py`** - -`AsyncHTTPClient` inherits from `HTTPClient`. It gets all the shared setup logic -(`__init__`, SSL context, base_url resolution, management_key handling, verbose mode, -`_get_default_headers`, `_raise_from_response`, `_parse_retry_after`, -`_raise_rate_limit_exception`, `base_url_for_project_id`) for free. - -Its `__init__` calls `super().__init__()` then creates the `httpx.AsyncClient`: - -```python -class AsyncHTTPClient(HTTPClient): - def __init__(self, project_id, base_url=None, *, timeout_seconds, secure, - management_key=None, verbose=False) -> None: - super().__init__(project_id, base_url, timeout_seconds=timeout_seconds, - secure=secure, management_key=management_key, verbose=verbose) - self._async_client = httpx.AsyncClient( - verify=self.client_verify, - timeout=self.timeout_seconds, - ) -``` - -Then override each transport method with `async def`: - -```python - async def get(self, uri, *, params=None, allow_redirects=True, pswd=None) -> httpx.Response: - response = await self._async_execute_with_retry( - lambda: self._async_client.get( - f"{self.base_url}{uri}", - headers=self._get_default_headers(pswd), - params=params, - follow_redirects=cast(bool, allow_redirects), - ) - ) - if self.verbose: - self._thread_local.last_response = DescopeResponse(response) - self._raise_from_response(response) - return response - - async def post(self, uri, *, body=None, params=None, pswd=None, base_url=None) -> httpx.Response: - ... # same pattern - - # put, patch, delete — same pattern - - async def _async_execute_with_retry(self, request_fn) -> httpx.Response: - response = await request_fn() - for delay in _RETRY_DELAYS_SECONDS: - if response.status_code not in _RETRY_STATUS_CODES: - break - await response.aclose() - await asyncio.sleep(delay) - response = await request_fn() - return response - - async def aclose(self) -> None: - await self._async_client.aclose() - - async def __aenter__(self) -> "AsyncHTTPClient": - return self - - async def __aexit__(self, *args) -> None: - await self.aclose() -``` - -**Type note**: Mypy will flag `async def get(...)` as an incompatible override of `HTTPClient.get` (sync → async return type change). Add `# type: ignore[override]` on each override. This is the only concession to type purity in the entire plan, and it's contained to `AsyncHTTPClient`. - ---- - -## Stage 2 — `Auth` stays sync - -`Auth` does CPU-bound JWT validation and a one-time public key fetch (using `HTTPClient`). -None of this needs to be async — JWT crypto is not I/O. `Auth` is unchanged. - -`AsyncDescopeClient` will create a regular (sync) `HTTPClient` solely to hand to `Auth` -for its internal public key fetch, then create an `AsyncHTTPClient` for all user-facing calls. -This is acceptable: the key fetch is a one-time initialization call, not per-request. - ---- - -## Stage 3 — `AsyncAuthBase` - -**File: `descope/_auth_base.py`** (add `AsyncAuthBase` alongside existing `AuthBase`) - -```python -class AsyncAuthBase: - """Base for async auth method classes.""" - - def __init__(self, auth: Auth, http: AsyncHTTPClient): - self._auth = auth # sync Auth — used only for JWT helpers (no I/O) - self._http = http # AsyncHTTPClient — used for all network calls -``` - -`self._auth` is used only for sync operations that don't touch the network: -`Auth.extract_masked_address()`, `Auth.validate_email()`, `Auth.compose_url()`, -`self._auth.generate_jwt_response()`, `self._auth.adjust_and_verify_delivery_method()`. -All of these are pure computation — no I/O — so keeping `Auth` sync is correct. - ---- - -## Stage 4 — Async authmethod classes - -**Pattern**: Each `Foo(AuthBase)` gets a sibling `AsyncFoo(AsyncAuthBase)` **in the same file**. -`AsyncFoo` inherits none of `Foo`'s instance methods (they're sync), but it *does* call -the same `@staticmethod _compose_*` methods which live on `Foo` and are referenced directly. - -```python -# In descope/authmethod/otp.py (after the existing OTP class) - -class AsyncOTP(AsyncAuthBase): - async def sign_in( - self, - method: DeliveryMethod, - login_id: str, - login_options: LoginOptions | None = None, - refresh_token: str | None = None, - ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - validate_refresh_token_provided(login_options, refresh_token) - uri = OTP._compose_signin_url(method) # reuse sync static helper - body = OTP._compose_signin_body(login_id, login_options) - response = await self._http.post(uri, body=body, pswd=refresh_token) # only change - return Auth.extract_masked_address(response.json(), method) - - async def sign_up(self, ...) -> str: ... - async def sign_up_or_in(self, ...) -> str: ... - async def verify_code(self, ...) -> dict: ... - async def update_user_email(self, ...) -> str: ... - async def update_user_phone(self, ...) -> str: ... -``` - -The `@staticmethod _compose_*` methods are called as `OTP._compose_signin_url(method)` — -they don't need to be duplicated, just referenced from the sync class. - -**Authmethod files to add `Async*` to**: -- `otp.py` → `AsyncOTP` -- `totp.py` → `AsyncTOTP` -- `magiclink.py` → `AsyncMagicLink` -- `enchantedlink.py` → `AsyncEnchantedLink` -- `oauth.py` → `AsyncOAuth` -- `saml.py` → `AsyncSAML` -- `sso.py` → `AsyncSSO` -- `webauthn.py` → `AsyncWebAuthn` -- `password.py` → `AsyncPassword` - -Each follows the identical pattern: same method signatures, same validation, same body/URL -composition via `Foo._compose_*` statics, only `await self._http.*()` instead of `self._http.*()`. - ---- - -## Stage 5 — Async management classes - -**Pattern**: Same as authmethod — `AsyncFoo` added at the bottom of each management file. -Management classes extend `HTTPBase` (not `AuthBase`), so their async counterpart -takes only `AsyncHTTPClient`. - -`AsyncHTTPBase` (add to `_http_base.py`): -```python -class AsyncHTTPBase: - def __init__(self, http: AsyncHTTPClient): - self._http = http -``` - -Each management async class: -```python -# In descope/management/user.py (after User class) - -class AsyncUser(AsyncHTTPBase): - async def create(self, login_id: str, email=None, ...) -> dict: - # same validation as User.create - uri = MgmtV1.user_create_path - body = User._compose_create_body(login_id, email, ...) # reuse static - response = await self._http.post(uri, body=body) - return response.json() - - async def delete(self, login_id: str) -> None: - ... - - # all other methods follow same pattern -``` - -Where `User` has inline body construction (no static helper), inline it identically in `AsyncUser`. -The duplication per method is 2-3 lines of dict construction — acceptable. - -**Management files to add `Async*` to**: -- `user.py` → `AsyncUser` -- `access_key.py` → `AsyncAccessKey` -- `audit.py` → `AsyncAudit` -- `authz.py` → `AsyncAuthz` -- `descoper.py` → `AsyncDescoper` -- `fga.py` → `AsyncFGA` -- `flow.py` → `AsyncFlow` -- `group.py` → `AsyncGroup` -- `jwt.py` → `AsyncJWT` -- `management_key.py` → `AsyncManagementKey` -- `outbound_application.py` → `AsyncOutboundApplication`, `AsyncOutboundApplicationByToken` -- `permission.py` → `AsyncPermission` -- `project.py` → `AsyncProject` -- `role.py` → `AsyncRole` -- `sso_application.py` → `AsyncSSOApplication` -- `sso_settings.py` → `AsyncSSOSettings` -- `tenant.py` → `AsyncTenant` - ---- - -## Stage 6 — `AsyncMGMT` - -**File: `descope/async_mgmt.py`** - -Mirrors `MGMT` exactly, substituting async management classes: - -```python -class AsyncMGMT: - def __init__(self, http: AsyncHTTPClient, auth: Auth, fga_cache_url=None): - self._http = http - self._user = AsyncUser(http) - self._access_key = AsyncAccessKey(http) - self._audit = AsyncAudit(http) - self._authz = AsyncAuthz(http, fga_cache_url=fga_cache_url) - # ... all other async management classes - - def _ensure_management_key(self, property_name: str): - # identical to MGMT._ensure_management_key - if not self._http.management_key: - raise AuthException(...) - - @property - def user(self) -> AsyncUser: - self._ensure_management_key("user") - return self._user - - # ... all other properties, identical structure to MGMT -``` - ---- - -## Stage 7 — `AsyncDescopeClient` - -**File: `descope/async_descope_client.py`** - -```python -class AsyncDescopeClient: - def __init__( - self, - project_id: str, - public_key: dict | None = None, - skip_verify: bool = False, - management_key: str | None = None, - timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - jwt_validation_leeway: int = 5, - auth_management_key: str | None = None, - fga_cache_url: str | None = None, - *, - base_url: str | None = None, - verbose: bool = False, - ): - project_id = project_id or os.getenv("DESCOPE_PROJECT_ID", "") - if not project_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "...") - - if skip_verify: - warnings.warn("⚠️ TLS verification disabled ...", UserWarning, stacklevel=2) - - # Auth uses a sync HTTPClient internally for one-time public key fetch. - # This is the only sync client created. It is not exposed to callers. - _auth_sync_http = HTTPClient( - project_id=project_id, - base_url=base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), - verbose=verbose, - ) - self._auth = Auth(project_id, public_key, jwt_validation_leeway, - http_client=_auth_sync_http) - - # All user-facing calls go through these async clients. - self._auth_http = AsyncHTTPClient( - project_id=project_id, - base_url=_auth_sync_http.base_url, # reuse resolved URL - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=auth_management_key or os.getenv("DESCOPE_AUTH_MANAGEMENT_KEY"), - verbose=verbose, - ) - self._mgmt_http = AsyncHTTPClient( - project_id=project_id, - base_url=_auth_sync_http.base_url, - timeout_seconds=timeout_seconds, - secure=not skip_verify, - management_key=management_key or os.getenv("DESCOPE_MANAGEMENT_KEY"), - verbose=verbose, - ) - - self._otp = AsyncOTP(self._auth, self._auth_http) - self._totp = AsyncTOTP(self._auth, self._auth_http) - self._magiclink = AsyncMagicLink(self._auth, self._auth_http) - self._enchantedlink = AsyncEnchantedLink(self._auth, self._auth_http) - self._oauth = AsyncOAuth(self._auth, self._auth_http) - self._saml = AsyncSAML(self._auth, self._auth_http) - self._sso = AsyncSSO(self._auth, self._auth_http) - self._webauthn = AsyncWebAuthn(self._auth, self._auth_http) - self._password = AsyncPassword(self._auth, self._auth_http) - - self._mgmt = AsyncMGMT(self._mgmt_http, self._auth, fga_cache_url=fga_cache_url) - - # Context manager support - async def __aenter__(self) -> "AsyncDescopeClient": - return self - - async def __aexit__(self, *args) -> None: - await self.aclose() - - async def aclose(self) -> None: - await self._auth_http.aclose() - await self._mgmt_http.aclose() - - # Properties — identical names to DescopeClient for API parity - @property - def otp(self) -> AsyncOTP: return self._otp - @property - def totp(self) -> AsyncTOTP: return self._totp - @property - def magiclink(self) -> AsyncMagicLink: return self._magiclink - @property - def enchantedlink(self) -> AsyncEnchantedLink: return self._enchantedlink - @property - def oauth(self) -> AsyncOAuth: return self._oauth - @property - def saml(self) -> AsyncSAML: return self._saml # deprecated - @property - def sso(self) -> AsyncSSO: return self._sso - @property - def webauthn(self) -> AsyncWebAuthn: return self._webauthn - @property - def password(self) -> AsyncPassword: return self._password - @property - def mgmt(self) -> AsyncMGMT: return self._mgmt - - # JWT validation helpers — remain sync (no I/O) - def validate_session(self, session_token: str) -> dict: - return self._auth.validate_session_request(session_token) - - def refresh_session(self, refresh_token: str) -> dict: - return self._auth.refresh_session(refresh_token) - - def validate_permissions(self, jwt_response: dict, permissions: list[str]) -> bool: - return self._auth.validate_permissions(jwt_response, permissions) - - # ... all other validate_*/get_matched_* methods from DescopeClient, unchanged - - def get_last_response(self) -> DescopeResponse | None: - # Returns from the auth http client (most recent call) - return self._auth_http.get_last_response() -``` - ---- - -## Stage 8 — Public API - -**`descope/__init__.py`**: Add `AsyncDescopeClient` to imports and `__all__`. - -```python -from descope.async_descope_client import AsyncDescopeClient -``` - -**Usage examples** (for README/docs, not in this plan): -```python -# As context manager (recommended): -async with AsyncDescopeClient(project_id="P...") as client: - masked = await client.otp.sign_in(DeliveryMethod.EMAIL, "user@example.com") - -# Standalone with explicit close: -client = AsyncDescopeClient(project_id="P...") -try: - masked = await client.otp.sign_in(DeliveryMethod.EMAIL, "user@example.com") -finally: - await client.aclose() -``` - ---- - -## File change summary - -| File | Action | -|------|--------| -| `descope/async_http_client.py` | **Create** — `AsyncHTTPClient(HTTPClient)` | -| `descope/_auth_base.py` | Add `AsyncAuthBase` class | -| `descope/_http_base.py` | Add `AsyncHTTPBase` class | -| `descope/async_descope_client.py` | **Create** — `AsyncDescopeClient` | -| `descope/async_mgmt.py` | **Create** — `AsyncMGMT` | -| `descope/authmethod/otp.py` | Add `AsyncOTP` class | -| `descope/authmethod/totp.py` | Add `AsyncTOTP` class | -| `descope/authmethod/magiclink.py` | Add `AsyncMagicLink` class | -| `descope/authmethod/enchantedlink.py` | Add `AsyncEnchantedLink` class | -| `descope/authmethod/oauth.py` | Add `AsyncOAuth` class | -| `descope/authmethod/saml.py` | Add `AsyncSAML` class | -| `descope/authmethod/sso.py` | Add `AsyncSSO` class | -| `descope/authmethod/webauthn.py` | Add `AsyncWebAuthn` class | -| `descope/authmethod/password.py` | Add `AsyncPassword` class | -| `descope/management/user.py` | Add `AsyncUser` class | -| `descope/management/access_key.py` | Add `AsyncAccessKey` class | -| `descope/management/audit.py` | Add `AsyncAudit` class | -| `descope/management/authz.py` | Add `AsyncAuthz` class | -| `descope/management/descoper.py` | Add `AsyncDescoper` class | -| `descope/management/fga.py` | Add `AsyncFGA` class | -| `descope/management/flow.py` | Add `AsyncFlow` class | -| `descope/management/group.py` | Add `AsyncGroup` class | -| `descope/management/jwt.py` | Add `AsyncJWT` class | -| `descope/management/management_key.py` | Add `AsyncManagementKey` class | -| `descope/management/outbound_application.py` | Add `AsyncOutboundApplication`, `AsyncOutboundApplicationByToken` | -| `descope/management/permission.py` | Add `AsyncPermission` class | -| `descope/management/project.py` | Add `AsyncProject` class | -| `descope/management/role.py` | Add `AsyncRole` class | -| `descope/management/sso_application.py` | Add `AsyncSSOApplication` class | -| `descope/management/sso_settings.py` | Add `AsyncSSOSettings` class | -| `descope/management/tenant.py` | Add `AsyncTenant` class | -| `descope/__init__.py` | Add `AsyncDescopeClient` export | - ---- - -## Key invariants to maintain throughout - -1. **`DescopeClient` and all sync classes are unchanged in behavior** — no regressions -2. **Every `AsyncFoo` method has an identical signature to its sync counterpart**, differing only in `async def` and `await` -3. **No `Union[T, Awaitable[T]]` anywhere** — sync returns `T`, async returns `Awaitable[T]` -4. **`Auth` is never async** — it holds public keys in memory after init, JWT validation is CPU-only -5. **`AsyncHTTPClient` is the only place that touches `httpx.AsyncClient`** -6. **`AsyncDescopeClient` owns the `AsyncHTTPClient` lifecycle** — callers use context manager or explicit `aclose()` From fadfc145f76e8edab31acd690f6265209528960f Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:57:37 +0300 Subject: [PATCH 09/17] refactor(tests): centralize JWTs and shared helpers in testutils Move PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN, and EXPIRED_SESSION_TOKEN from individual test files into testutils.py. Move assert_http_called into conftest.py. Replace old test files with the unified sync/async parity versions. Co-Authored-By: Claude Sonnet 4.6 --- tests/conftest.py | 48 +- tests/test_descope_client.py | 1352 ++++++++++++++++------------------ tests/test_totp.py | 326 ++++---- tests/testutils.py | 41 ++ 4 files changed, 853 insertions(+), 914 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 056ee2015..7ab2750c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,32 @@ import pytest +from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.descope_client import DescopeClient from descope.descope_client_async import DescopeClientAsync from tests.common import DEFAULT_BASE_URL +from tests.testutils import PUBLIC_KEY_DICT, SSLMatcher + +# --------------------------------------------------------------------------- +# Claude Code sandbox workaround — DO NOT COMMIT uncommented +# +# Claude Code's sandbox routes traffic through a local SOCKS5 proxy. httpx +# picks it up automatically (trust_env=True) but socksio isn't installed, so +# async fixture construction fails with: +# ImportError: Using SOCKS proxy, but the 'socksio' package is not installed. +# +# Uncomment the fixture below to suppress proxy pickup during the test session. +# +# @pytest.fixture(autouse=True) +# def _disable_httpx_proxy(): +# import httpx +# _orig = httpx.AsyncClient.__init__ +# def _patched(self, *args, **kwargs): +# kwargs.setdefault("trust_env", False) +# _orig(self, *args, **kwargs) +# with patch.object(httpx.AsyncClient, "__init__", _patched): +# yield +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Shared test constants @@ -17,24 +40,25 @@ PROJECT_ID = "dummy" -# ES384 key — kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW, used by the JWT test tokens throughout -# test_descope_client.py and test_descope_client_unified.py. -PUBLIC_KEY_DICT = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", -} - # --------------------------------------------------------------------------- # Response factory # --------------------------------------------------------------------------- +def assert_http_called(mock_http, mode, url, **kwargs): + """Assert the patched HTTP mock was called with the given arguments. + + In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode + they are set on the ``httpx.AsyncClient`` constructor and absent from each call. + This helper injects them automatically for sync so test bodies stay identical. + """ + if mode == "sync": + kwargs.setdefault("verify", SSLMatcher()) + kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) + mock_http.assert_called_with(url, **kwargs) + + def make_response(json_data=None, *, status=200, cookies=None): """Build a mock httpx.Response usable as the return value of a mocked HTTP call.""" m = MagicMock() diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 9f77b0947..c642a8f3d 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -1,18 +1,19 @@ +from __future__ import annotations + import json -import os import sys -import unittest from copy import deepcopy from unittest import mock from unittest.mock import patch +import pytest + from descope import ( API_RATE_LIMIT_RETRY_AFTER_HEADER, ERROR_TYPE_API_RATE_LIMIT, SESSION_COOKIE_NAME, AccessKeyLoginOptions, AuthException, - DescopeClient, RateLimitException, ) from descope.common import ( @@ -21,401 +22,386 @@ DeliveryMethod, EndpointsV1, ) -from tests.testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import ( + EXPIRED_SESSION_TOKEN, + PUBLIC_KEY_DICT, + VALID_REFRESH_TOKEN, + VALID_SESSION_TOKEN, + SSLMatcher, +) from . import common +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +PUBLIC_KEY_STR = json.dumps(PUBLIC_KEY_DICT) + +# The original setUp public_key_dict (kid=2Bt5…) used by a handful of tests +DUMMY_PUBLIC_KEY_DICT = { + "alg": "ES384", + "crv": "P-384", + "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", + "kty": "EC", + "use": "sig", + "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", + "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", +} + + +EXPECTED_USER_ID = "U2CuCPuJgPWHGB5P4GmfbuPGhGVm" +EXPECTED_PROJECT_ID = "P2CuC9yv2UGtGI1o84gCZEb9qEQW" + +# Tokens ported from test_descope_client.py that must fail validate_session +_INVALID_HEADER_TOKEN = ( + "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0" + ".Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGP" + "HPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" +) +_MISSING_KID_TOKEN = ( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0" + ".eyJleHAiOjE5ODEzOTgxMTF9" + ".GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP" + "3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" +) +_INVALID_PAYLOAD_TOKEN = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" -class TestDescopeClient(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", - } - self.public_key_str = json.dumps(self.public_key_dict) - - def test_descope_client(self): - self.assertRaises(AuthException, DescopeClient, project_id=None, public_key="dummy") - self.assertRaises(AuthException, DescopeClient, project_id="", public_key="dummy") - - with patch("os.getenv") as mock_getenv: - mock_getenv.return_value = "" - self.assertRaises(AuthException, DescopeClient, project_id=None, public_key="dummy") - - self.assertIsNotNone(AuthException, DescopeClient(project_id="dummy", public_key=None)) - self.assertIsNotNone(AuthException, DescopeClient(project_id="dummy", public_key="")) - self.assertRaises( - AuthException, - DescopeClient, - project_id="dummy", - public_key="not dict object", - ) - self.assertIsNotNone(DescopeClient(project_id="dummy", public_key=self.public_key_str)) - - def test_project_id_from_env_without_env(self): - os.environ["DESCOPE_PROJECT_ID"] = "" - self.assertRaises(AuthException, DescopeClient, "") - - def test_mgmt(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set - self.assertRaises(AuthException, lambda: client.mgmt.tenant) - self.assertRaises(AuthException, lambda: client.mgmt.sso_application) - self.assertRaises(AuthException, lambda: client.mgmt.user) - self.assertRaises(AuthException, lambda: client.mgmt.access_key) - self.assertRaises(AuthException, lambda: client.mgmt.sso) - self.assertRaises(AuthException, lambda: client.mgmt.jwt) - self.assertRaises(AuthException, lambda: client.mgmt.permission) - self.assertRaises(AuthException, lambda: client.mgmt.role) - self.assertRaises(AuthException, lambda: client.mgmt.group) - self.assertRaises(AuthException, lambda: client.mgmt.flow) - self.assertRaises(AuthException, lambda: client.mgmt.audit) - self.assertRaises(AuthException, lambda: client.mgmt.authz) - self.assertRaises(AuthException, lambda: client.mgmt.fga) - self.assertRaises(AuthException, lambda: client.mgmt.project) - self.assertRaises(AuthException, lambda: client.mgmt.outbound_application) - - # Validate that outbound_application_by_token doesnt require mgmt key - try: - client.mgmt.outbound_application_by_token - except AuthException: - self.fail("failed to initiate outbound_application_by_token without management key") - - def test_logout(self): - dummy_refresh_token = "" - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises(AuthException, client.logout, None) - - # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.logout, dummy_refresh_token) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.logout(dummy_refresh_token)) - - def test_logout_all(self): - dummy_refresh_token = "" - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises(AuthException, client.logout_all, None) - - # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.logout_all, dummy_refresh_token) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.logout_all(dummy_refresh_token)) - - def test_me(self): - dummy_refresh_token = "" - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises(AuthException, client.me, None) - - # Test failed flow - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.me, dummy_refresh_token) - - # Test success flow - with patch("httpx.get") as mock_get: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"name": "Testy McTester", "email": "testy@tester.com"}""") - my_mock_response.json.return_value = data - mock_get.return_value = my_mock_response - user_response = client.me(dummy_refresh_token) - self.assertIsNotNone(user_response) - self.assertEqual(data["name"], user_response["name"]) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.me_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - follow_redirects=None, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_my_tenants(self): - dummy_refresh_token = "" - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - self.assertRaises(AuthException, client.my_tenants, None) - self.assertRaises(AuthException, client.my_tenants, dummy_refresh_token) - self.assertRaises(AuthException, client.my_tenants, dummy_refresh_token, True, ["a"]) +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- - # Test failed flow - with patch("httpx.post") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.my_tenants, dummy_refresh_token, True) - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"tenants": [{"id": "tenant_id", "name": "tenant_name"}]}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - tenant_response = client.my_tenants(dummy_refresh_token, False, ["a"]) - self.assertIsNotNone(tenant_response) - self.assertEqual(data["tenants"][0]["name"], tenant_response["tenants"][0]["name"]) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.my_tenants_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={"dct": False, "ids": ["a"]}, - follow_redirects=False, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) +class TestDescopeClient: + # ------------------------------------------------------------------ + # Construction validation + # ------------------------------------------------------------------ - def test_history(self): - dummy_refresh_token = "" - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + async def test_descope_client(self, client_factory): + with pytest.raises(AuthException): + client_factory.make(None, "dummy") + with pytest.raises(AuthException): + client_factory.make("", "dummy") - self.assertRaises(AuthException, client.history, None) + with patch("os.getenv") as mock_getenv: + mock_getenv.return_value = "" + with pytest.raises(AuthException): + client_factory.make(None, "dummy") - # Test failed flow - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.history, dummy_refresh_token) + assert client_factory.make(PROJECT_ID, None) is not None + assert client_factory.make(PROJECT_ID, "") is not None + with pytest.raises(AuthException): + client_factory.make(PROJECT_ID, "not dict object") + assert client_factory.make(PROJECT_ID, PUBLIC_KEY_STR) is not None - # Test success flow - with patch("httpx.get") as mock_get: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads( - """ - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32 - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23 - } - ] - """ - ) - my_mock_response.json.return_value = data - mock_get.return_value = my_mock_response - user_response = client.history(dummy_refresh_token) - self.assertIsNotNone(user_response) - self.assertEqual(data, user_response) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.history_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - follow_redirects=None, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) + async def test_project_id_from_env_without_env(self, client_factory): + with patch.dict("os.environ", {"DESCOPE_PROJECT_ID": ""}): + with pytest.raises(AuthException): + client_factory.make("") - def test_validate_session(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + # ------------------------------------------------------------------ + # Management client (sync-only) + # ------------------------------------------------------------------ - invalid_header_jwt_token = "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0.Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGPHPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" - missing_kid_header_jwt_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0.eyJleHAiOjE5ODEzOTgxMTF9.GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" - invalid_payload_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" + async def test_mgmt(self, descope_client): + if descope_client.mode != "sync": + pytest.skip("mgmt not available on DescopeClientAsync") - self.assertRaises( - AuthException, - client.validate_session, - missing_kid_header_jwt_token, - ) - self.assertRaises( - AuthException, - client.validate_session, - invalid_header_jwt_token, - ) - self.assertRaises( - AuthException, - client.validate_session, - invalid_payload_jwt_token, + # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set + with pytest.raises(AuthException): + _ = descope_client.mgmt.tenant + with pytest.raises(AuthException): + _ = descope_client.mgmt.sso_application + with pytest.raises(AuthException): + _ = descope_client.mgmt.user + with pytest.raises(AuthException): + _ = descope_client.mgmt.access_key + with pytest.raises(AuthException): + _ = descope_client.mgmt.sso + with pytest.raises(AuthException): + _ = descope_client.mgmt.jwt + with pytest.raises(AuthException): + _ = descope_client.mgmt.permission + with pytest.raises(AuthException): + _ = descope_client.mgmt.role + with pytest.raises(AuthException): + _ = descope_client.mgmt.group + with pytest.raises(AuthException): + _ = descope_client.mgmt.flow + with pytest.raises(AuthException): + _ = descope_client.mgmt.audit + with pytest.raises(AuthException): + _ = descope_client.mgmt.authz + with pytest.raises(AuthException): + _ = descope_client.mgmt.fga + with pytest.raises(AuthException): + _ = descope_client.mgmt.project + with pytest.raises(AuthException): + _ = descope_client.mgmt.outbound_application + + # Validate that outbound_application_by_token doesn't require mgmt key + try: + _ = descope_client.mgmt.outbound_application_by_token + except AuthException: + pytest.fail("failed to initiate outbound_application_by_token without management key") + + # ------------------------------------------------------------------ + # logout / logout_all + # ------------------------------------------------------------------ + + async def test_logout(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout(None)) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout("")) + + with descope_client.mock_post(make_response(status=200)): + assert await descope_client.invoke(descope_client.logout("")) is not None + + async def test_logout_all(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout_all(None)) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.logout_all("")) + + with descope_client.mock_post(make_response(status=200)): + assert await descope_client.invoke(descope_client.logout_all("")) is not None + + # ------------------------------------------------------------------ + # me + # ------------------------------------------------------------------ + + async def test_me(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.me(None)) + + with descope_client.mock_get(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.me("")) + + data = json.loads("""{"name": "Testy McTester", "email": "testy@tester.com"}""") + with descope_client.mock_get(make_response(data)) as mock_get: + user_response = await descope_client.invoke(descope_client.me("")) + assert user_response is not None + assert data["name"] == user_response["name"] + assert_http_called( + mock_get, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.me_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + follow_redirects=None, + params=None, ) - # Test case where header_alg != key[alg] - client4 = DescopeClient(self.dummy_project_id, None) - self.assertRaises( - AuthException, - client4.validate_session, - None, + # ------------------------------------------------------------------ + # my_tenants + # ------------------------------------------------------------------ + + async def test_my_tenants(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants(None)) + + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("")) + + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("", True, ["a"])) + + with descope_client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.my_tenants("", True)) + + data = json.loads("""{"tenants": [{"id": "tenant_id", "name": "tenant_name"}]}""") + with descope_client.mock_post(make_response(data)) as mock_post: + tenant_response = await descope_client.invoke(descope_client.my_tenants("", False, ["a"])) + assert tenant_response is not None + assert data["tenants"][0]["name"] == tenant_response["tenants"][0]["name"] + assert_http_called( + mock_post, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.my_tenants_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + json={"dct": False, "ids": ["a"]}, + follow_redirects=False, + params=None, ) - def test_validate_session_response_structure(self): - self.maxDiff = None - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", + # ------------------------------------------------------------------ + # history + # ------------------------------------------------------------------ + + async def test_history(self, descope_client): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.history(None)) + + with descope_client.mock_get(make_response(status=500)): + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.history("")) + + data = json.loads( + """ + [ + { + "userId": "kuku", + "city": "kefar saba", + "country": "Israel", + "ip": "1.1.1.1", + "loginTime": 32 + }, + { + "userId": "nunu", + "city": "eilat", + "country": "Israele", + "ip": "1.1.1.2", + "loginTime": 23 + } + ] + """ + ) + with descope_client.mock_get(make_response(data)) as mock_get: + user_response = await descope_client.invoke(descope_client.history("")) + assert user_response is not None + assert data == user_response + assert_http_called( + mock_get, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.history_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, }, + follow_redirects=None, + params=None, ) - ds = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1pSY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" - try: - jwt_response = client.validate_session(ds) - except AuthException: - self.fail("Should pass validation") - - self.assertEqual( - jwt_response, - { + # ------------------------------------------------------------------ + # validate_session — pure-CPU helper (no IO) + # ------------------------------------------------------------------ + + async def test_validate_session(self, client_factory): + # Client with the 2Bt5 key (matching the kid in _INVALID_PAYLOAD_TOKEN) + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) + + with pytest.raises(AuthException): + client.validate_session(_MISSING_KID_TOKEN) + with pytest.raises(AuthException): + client.validate_session(_INVALID_HEADER_TOKEN) + with pytest.raises(AuthException): + client.validate_session(_INVALID_PAYLOAD_TOKEN) + + # None key client + None token + client4 = client_factory.make(PROJECT_ID, None) + with pytest.raises(AuthException): + client4.validate_session(None) + + async def test_validate_session_response_structure(self, descope_client): + result = descope_client.validate_session(VALID_SESSION_TOKEN) + assert result == { + "drn": "DS", + "exp": 2493061415, + "iat": 1659643061, + "iss": EXPECTED_PROJECT_ID, + "sub": EXPECTED_USER_ID, + "jwt": VALID_SESSION_TOKEN, + "permissions": [], + "roles": [], + "tenants": {}, + "projectId": EXPECTED_PROJECT_ID, + "userId": EXPECTED_USER_ID, + "sessionToken": { "drn": "DS", "exp": 2493061415, "iat": 1659643061, - "iss": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "sub": "U2CuCPuJgPWHGB5P4GmfbuPGhGVm", - "jwt": "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1pSY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR", - "permissions": [], - "roles": [], - "tenants": {}, - "projectId": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "userId": "U2CuCPuJgPWHGB5P4GmfbuPGhGVm", - "sessionToken": { - "drn": "DS", - "exp": 2493061415, - "iat": 1659643061, - "iss": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "sub": "U2CuCPuJgPWHGB5P4GmfbuPGhGVm", - "jwt": "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1pSY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR", - }, + "iss": EXPECTED_PROJECT_ID, + "sub": EXPECTED_USER_ID, + "jwt": VALID_SESSION_TOKEN, }, - ) - - def test_validate_session_valid_tokens(self): - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", - }, - ) + } + async def test_validate_session_valid_tokens(self, client_factory): + # Client with P2Cu key preloaded + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) dummy_refresh_token = "refresh" - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" + valid_jwt_token = VALID_REFRESH_TOKEN # far-future DSR token, P2Cu kid - try: - client.validate_session(valid_jwt_token) - except AuthException: - self.fail("Should pass validation") + # Valid token validates locally — no network needed + client.validate_session(valid_jwt_token) - self.assertIsNotNone(client.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) + assert ( + await client.invoke(client.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) is not None + ) - # Test case where key id cannot be found - client2 = DescopeClient(self.dummy_project_id, None) + # Key id cannot be found — key fetch returns wrong kid + client2 = client_factory.make(PROJECT_ID, None) with patch("httpx.get") as mock_request: - fake_key = deepcopy(self.public_key_dict) - # overwrite the kid (so it will not be found) + fake_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) fake_key["kid"] = "dummy_kid" mock_request.return_value.text = json.dumps([fake_key]) mock_request.return_value.is_success = True - self.assertRaises( - AuthException, - client2.validate_and_refresh_session, - valid_jwt_token, - dummy_refresh_token, - ) + with pytest.raises(AuthException): + await client2.invoke(client2.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - # Test case where we failed to load key - client3 = DescopeClient(self.dummy_project_id, None) + # Key fetch returns unparsable key + client3 = client_factory.make(PROJECT_ID, None) with patch("httpx.get") as mock_request: mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" mock_request.return_value.is_success = True - self.assertRaises( - AuthException, - client3.validate_and_refresh_session, - valid_jwt_token, - dummy_refresh_token, - ) + with pytest.raises(AuthException): + await client3.invoke(client3.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - # Test case where header_alg != key[alg] - self.public_key_dict["alg"] = "ES521" - client4 = DescopeClient(self.dummy_project_id, self.public_key_dict) + # header_alg != key[alg] + bad_alg_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) + bad_alg_key["alg"] = "ES521" + client4 = client_factory.make(PROJECT_ID, bad_alg_key) with patch("httpx.get") as mock_request: mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" mock_request.return_value.is_success = True - self.assertRaises( - AuthException, - client4.validate_and_refresh_session, - valid_jwt_token, - dummy_refresh_token, - ) + with pytest.raises(AuthException): + await client4.invoke(client4.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - # Test case where header_alg != key[alg] - client4 = DescopeClient(self.dummy_project_id, None) - self.assertRaises( - AuthException, - client4.validate_and_refresh_session, - None, - None, - ) + # Both session_token and refresh_token are None + client4b = client_factory.make(PROJECT_ID, None) + with pytest.raises(AuthException): + await client4b.invoke(client4b.validate_and_refresh_session(None, None)) - # - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvDNgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" - valid_refresh_token = valid_jwt_token + # Expired session triggers refresh; refreshed token is also expired → fails + expired_jwt_token = EXPIRED_SESSION_TOKEN + valid_refresh_for_expire_test = valid_jwt_token with patch("httpx.get") as mock_request: mock_request.return_value.cookies = {SESSION_COOKIE_NAME: expired_jwt_token} mock_request.return_value.is_success = True + with pytest.raises(AuthException): + await client3.invoke( + client3.validate_and_refresh_session(expired_jwt_token, valid_refresh_for_expire_test) + ) - self.assertRaises( - AuthException, - client3.validate_and_refresh_session, - expired_jwt_token, - valid_refresh_token, - ) + # ------------------------------------------------------------------ + # Exception object shapes (no client needed) + # ------------------------------------------------------------------ def test_exception_object(self): ex = AuthException(401, "dummy-type", "dummy error message") - self.assertIsNotNone(str(ex)) - self.assertIsNotNone(repr(ex)) - self.assertEqual(ex.status_code, 401) - self.assertEqual(ex.error_type, "dummy-type") - self.assertEqual(ex.error_message, "dummy error message") + assert str(ex) is not None + assert repr(ex) is not None + assert ex.status_code == 401 + assert ex.error_type == "dummy-type" + assert ex.error_message == "dummy error message" def test_api_rate_limit_exception_object(self): ex = RateLimitException( @@ -425,401 +411,337 @@ def test_api_rate_limit_exception_object(self): "API rate limit exceeded", {API_RATE_LIMIT_RETRY_AFTER_HEADER: "9"}, ) - self.assertIsNotNone(str(ex)) - self.assertIsNotNone(repr(ex)) - self.assertEqual(ex.status_code, 429) - self.assertEqual(ex.error_type, ERROR_TYPE_API_RATE_LIMIT) - self.assertEqual(ex.error_description, "API rate limit exceeded description") - self.assertEqual(ex.error_message, "API rate limit exceeded") - self.assertEqual(ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, ""), "9") - - def test_expired_token(self): - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - dummy_refresh_token = "dummy refresh token" - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", - }, + assert str(ex) is not None + assert repr(ex) is not None + assert ex.status_code == 429 + assert ex.error_type == ERROR_TYPE_API_RATE_LIMIT + assert ex.error_description == "API rate limit exceeded description" + assert ex.error_message == "API rate limit exceeded" + assert ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, "") == "9" + + # ------------------------------------------------------------------ + # Expired token + refresh flows + # ------------------------------------------------------------------ + + async def test_expired_token(self, client_factory): + # expired DS token (kid=P2Cu, exp=1657798328 — past) + expired_jwt_token = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9" + ".eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9" + ".i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" ) + dummy_refresh_token = "dummy refresh token" + + # Client with P2Cu key (same kid the validate tokens use in refresh path) + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - # Test fail flow + # Fail flow: key is preloaded so validate_session raises due to expiration with patch("httpx.get") as mock_request: mock_request.return_value.is_success = False - self.assertRaises( - AuthException, - client.validate_session, - expired_jwt_token, - ) + with pytest.raises(AuthException): + client.validate_session(expired_jwt_token) with patch("httpx.get") as mock_request: mock_request.return_value.cookies = {"aaa": "aaa"} mock_request.return_value.is_success = True - self.assertRaises( - AuthException, - client.validate_session, - expired_jwt_token, - ) + with pytest.raises(AuthException): + client.validate_session(expired_jwt_token) - # Test fail flow + # Fail flow: jwt.get_unverified_header returns {} (no kid) dummy_session_token = "dummy session token" - dummy_client = DescopeClient(self.dummy_project_id, self.public_key_dict) + # dummy_client has the 2Bt5 key; EXPIRED_SESSION_TOKEN uses P2Cu — key not loaded + dummy_client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: mock_jwt_get_unverified_header.return_value = {} - self.assertRaises( - AuthException, - dummy_client.validate_and_refresh_session, - dummy_session_token, - dummy_refresh_token, - ) - - # Test success flow - new_session_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1pSY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" - valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" - expired_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvDNgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" - with patch("httpx.post") as mock_request: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"sessionJwt": new_session_token} - mock_request.return_value = my_mock_response - mock_request.return_value.cookies = {} + with pytest.raises(AuthException): + await dummy_client.invoke( + dummy_client.validate_and_refresh_session(dummy_session_token, dummy_refresh_token) + ) + # Success flow: expired token → POST refresh → returns valid new session token + new_session_token = VALID_SESSION_TOKEN + valid_refresh_token = VALID_REFRESH_TOKEN + expired_token = EXPIRED_SESSION_TOKEN + resp = make_response({"sessionJwt": new_session_token}, cookies={}) + with client.mock_post(resp): # Refresh because of expiration - resp = client.validate_and_refresh_session(expired_token, valid_refresh_token) - - new_session_token_from_request = resp[SESSION_TOKEN_NAME]["jwt"] - self.assertEqual( - new_session_token_from_request, - new_session_token, - "Failed to refresh token", - ) + result = await client.invoke(client.validate_and_refresh_session(expired_token, valid_refresh_token)) + new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] + assert new_session_token_from_request == new_session_token, "Failed to refresh token" # Refresh explicitly - resp = client.refresh_session(valid_refresh_token) - - new_session_token_from_request = resp[SESSION_TOKEN_NAME]["jwt"] - self.assertEqual( - new_session_token_from_request, - new_session_token, - "Failed to refresh token", - ) - - expired_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9.wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvDNgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" - valid_refresh_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" - new_refreshed_token = expired_jwt_token # the refreshed token should be invalid (or expired) + result = await client.invoke(client.refresh_session(valid_refresh_token)) + new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] + assert new_session_token_from_request == new_session_token, "Failed to refresh token" + + # Fail flow: refreshed token is also expired → AuthException + # dummy_client has P2Cu key; expired_jwt_token (kid=2Bt5) is NOT preloaded → triggers + # JWKS fetch via httpx.get; mock returns garbage JSON → AuthException + expired_jwt_token2 = EXPIRED_SESSION_TOKEN + valid_refresh_token2 = VALID_REFRESH_TOKEN + new_refreshed_token = expired_jwt_token2 with patch("httpx.get") as mock_request: my_mock_response = mock.Mock() my_mock_response.is_success = True my_mock_response.json.return_value = {"sessionJwt": new_refreshed_token} mock_request.return_value = my_mock_response mock_request.return_value.cookies = {} - self.assertRaises( - AuthException, - dummy_client.validate_and_refresh_session, - expired_jwt_token, - valid_refresh_token, - ) + with pytest.raises(AuthException): + await dummy_client.invoke( + dummy_client.validate_and_refresh_session(expired_jwt_token2, valid_refresh_token2) + ) + + # ------------------------------------------------------------------ + # Public key loading errors + # ------------------------------------------------------------------ - def test_public_key_load(self): + async def test_public_key_load(self, client_factory): # Test key without kty property - invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) invalid_public_key.pop("kty") - with self.assertRaises(AuthException) as cm: - DescopeClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 500) + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 # Test key without kid property - invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) invalid_public_key.pop("kid") - with self.assertRaises(AuthException) as cm: - DescopeClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 500) + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 # Test key with unknown algorithm - invalid_public_key = deepcopy(self.public_key_dict) + invalid_public_key = deepcopy(PUBLIC_KEY_DICT) invalid_public_key["alg"] = "unknown algorithm" - with self.assertRaises(AuthException) as cm: - DescopeClient(self.dummy_project_id, invalid_public_key) - self.assertEqual(cm.exception.status_code, 500) - - def test_client_properties(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - self.assertIsNotNone(client) - self.assertIsNotNone(client.magiclink, "Empty Magiclink object") - self.assertIsNotNone(client.otp, "Empty otp object") - self.assertIsNotNone(client.totp, "Empty totp object") - self.assertIsNotNone(client.oauth, "Empty oauth object") - self.assertIsNotNone(client.saml, "Empty saml object") - self.assertIsNotNone(client.sso, "Empty saml object") - self.assertIsNotNone(client.webauthn, "Empty webauthN object") - - def test_validate_permissions(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + with pytest.raises(AuthException) as exc_info: + client_factory.make(PROJECT_ID, invalid_public_key) + assert exc_info.value.status_code == 500 + + # ------------------------------------------------------------------ + # Client property surface + # ------------------------------------------------------------------ + + async def test_client_properties(self, descope_client): + # totp is available on both sync and async clients + assert descope_client.totp is not None, "Empty totp object" + + # All other auth-method properties are sync-only + if descope_client.mode != "sync": + return + assert descope_client.magiclink is not None, "Empty Magiclink object" + assert descope_client.otp is not None, "Empty otp object" + assert descope_client.oauth is not None, "Empty oauth object" + assert descope_client.saml is not None, "Empty saml object" + assert descope_client.sso is not None, "Empty saml object" + assert descope_client.webauthn is not None, "Empty webauthN object" + + # ------------------------------------------------------------------ + # Permission / role helpers — pure-CPU + # ------------------------------------------------------------------ + + async def test_validate_permissions(self, descope_client): jwt_response = {} - self.assertFalse(client.validate_permissions(jwt_response, ["Perm 1"])) + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False jwt_response = {"permissions": []} - self.assertFalse(client.validate_permissions(jwt_response, ["Perm 1"])) - self.assertTrue(client.validate_permissions(jwt_response, [])) + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False + assert descope_client.validate_permissions(jwt_response, []) is True jwt_response = {"permissions": ["Perm 1"]} - self.assertTrue(client.validate_permissions(jwt_response, "Perm 1")) - self.assertTrue(client.validate_permissions(jwt_response, ["Perm 1"])) - self.assertFalse(client.validate_permissions(jwt_response, ["Perm 2"])) + assert descope_client.validate_permissions(jwt_response, "Perm 1") is True + assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is True + assert descope_client.validate_permissions(jwt_response, ["Perm 2"]) is False # Tenant level jwt_response = {"tenants": {}} - self.assertFalse(client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"])) + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False jwt_response = {"tenants": {"t1": {}}} - self.assertFalse(client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"])) + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False jwt_response = {"tenants": {"t1": {"permissions": "Perm 1"}}} - self.assertTrue(client.validate_tenant_permissions(jwt_response, "t1", [])) - self.assertTrue(client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1"])) - self.assertFalse(client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"])) - self.assertFalse(client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"])) - self.assertFalse(client.validate_tenant_permissions(jwt_response, "t2", [])) - - def test_get_matched_permissions(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + assert descope_client.validate_tenant_permissions(jwt_response, "t1", []) is True + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1"]) is True + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False + assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False + assert descope_client.validate_tenant_permissions(jwt_response, "t2", []) is False + + async def test_get_matched_permissions(self, descope_client): jwt_response = {} - self.assertEqual(client.get_matched_permissions(jwt_response, []), []) + assert descope_client.get_matched_permissions(jwt_response, []) == [] jwt_response = {"permissions": []} - self.assertEqual(client.get_matched_permissions(jwt_response, ["Perm 1"]), []) + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == [] jwt_response = {"permissions": ["Perm 1", "Perm 2"]} - self.assertEqual(client.get_matched_permissions(jwt_response, ["Perm 1"]), ["Perm 1"]) - self.assertEqual( - client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2"]), - ["Perm 1", "Perm 2"], - ) - self.assertEqual( - client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2", "Perm 3"]), - ["Perm 1", "Perm 2"], - ) + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == ["Perm 1"] + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2"]) == ["Perm 1", "Perm 2"] + assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2", "Perm 3"]) == [ + "Perm 1", + "Perm 2", + ] # Tenant level jwt_response = {"tenants": {}} - self.assertEqual(client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]), []) + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] jwt_response = {"tenants": {"t1": {}}} - self.assertEqual(client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]), []) + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] jwt_response = {"tenants": {"t1": {"permissions": ["Perm 1", "Perm 2"]}}} - self.assertEqual( - client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]), - ["Perm 1"], - ) - self.assertEqual( - client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]), - ["Perm 1", "Perm 2"], - ) - self.assertEqual( - client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2", "Perm 3"]), - ["Perm 1", "Perm 2"], - ) - - def test_validate_roles(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == ["Perm 1"] + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) == [ + "Perm 1", + "Perm 2", + ] + assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2", "Perm 3"]) == [ + "Perm 1", + "Perm 2", + ] + + async def test_validate_roles(self, descope_client): jwt_response = {} - self.assertFalse(client.validate_roles(jwt_response, ["Role 1"])) + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False jwt_response = {"roles": []} - self.assertFalse(client.validate_roles(jwt_response, ["Role 1"])) - self.assertTrue(client.validate_roles(jwt_response, [])) + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False + assert descope_client.validate_roles(jwt_response, []) is True jwt_response = {"roles": ["Role 1"]} - self.assertTrue(client.validate_roles(jwt_response, "Role 1")) - self.assertTrue(client.validate_roles(jwt_response, ["Role 1"])) - self.assertFalse(client.validate_roles(jwt_response, ["Role 2"])) + assert descope_client.validate_roles(jwt_response, "Role 1") is True + assert descope_client.validate_roles(jwt_response, ["Role 1"]) is True + assert descope_client.validate_roles(jwt_response, ["Role 2"]) is False # Tenant level jwt_response = {"tenants": {}} - self.assertFalse(client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"])) + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False jwt_response = {"tenants": {"t1": {}}} - self.assertFalse(client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"])) + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False jwt_response = {"tenants": {"t1": {"roles": "Role 1"}}} - self.assertTrue(client.validate_tenant_roles(jwt_response, "t1", ["Role 1"])) - self.assertTrue(client.validate_tenant_roles(jwt_response, "t1", [])) - self.assertFalse(client.validate_tenant_roles(jwt_response, "t1", ["Role 2"])) - self.assertFalse(client.validate_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"])) - self.assertFalse(client.validate_tenant_roles(jwt_response, "t1", ["Perm 1", "Perm 2"])) - - def test_get_matched_roles(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1"]) is True + assert descope_client.validate_tenant_roles(jwt_response, "t1", []) is True + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 2"]) is False + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) is False + assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False + + async def test_get_matched_roles(self, descope_client): jwt_response = {} - self.assertEqual(client.get_matched_roles(jwt_response, []), []) + assert descope_client.get_matched_roles(jwt_response, []) == [] jwt_response = {"roles": []} - self.assertEqual(client.get_matched_roles(jwt_response, ["Role 1"]), []) + assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == [] jwt_response = {"roles": ["Role 1", "Role 2"]} - self.assertEqual(client.get_matched_roles(jwt_response, ["Role 1"]), ["Role 1"]) - self.assertEqual( - client.get_matched_roles(jwt_response, ["Role 1", "Role 2"]), - ["Role 1", "Role 2"], - ) - self.assertEqual( - client.get_matched_roles(jwt_response, ["Role 1", "Role 2", "Role 3"]), - ["Role 1", "Role 2"], - ) + assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == ["Role 1"] + assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] + assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2", "Role 3"]) == ["Role 1", "Role 2"] # Tenant level jwt_response = {"tenants": {}} - self.assertEqual(client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]), []) + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] jwt_response = {"tenants": {"t1": {}}} - self.assertEqual(client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]), []) + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] jwt_response = {"tenants": {"t1": {"roles": ["Role 1", "Role 2"]}}} - self.assertEqual(client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]), ["Role 1"]) - self.assertEqual( - client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]), - ["Role 1", "Role 2"], - ) - self.assertEqual( - client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2", "Role 3"]), - ["Role 1", "Role 2"], - ) - - def test_exchange_access_key_empty_param(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - with self.assertRaises(AuthException) as cm: - client.exchange_access_key("") - self.assertEqual(cm.exception.status_code, 400) - - def test_exchange_access_key(self): - # client = DescopeClient(self.dummy_project_id, self.public_key_dict) - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", - }, - ) + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == ["Role 1"] + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] + assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2", "Role 3"]) == [ + "Role 1", + "Role 2", + ] + + # ------------------------------------------------------------------ + # exchange_access_key + # ------------------------------------------------------------------ + + async def test_exchange_access_key_empty_param(self, descope_client): + with pytest.raises(AuthException) as exc_info: + await descope_client.invoke(descope_client.exchange_access_key("")) + assert exc_info.value.status_code == 400 + + async def test_exchange_access_key(self, descope_client): dummy_access_key = "dummy access key" - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = {"sessionJwt": valid_jwt_token} - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - jwt_response = client.exchange_access_key( - access_key=dummy_access_key, - login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), + resp = make_response({"sessionJwt": VALID_REFRESH_TOKEN}) + with descope_client.mock_post(resp) as mock_post: + jwt_response = await descope_client.invoke( + descope_client.exchange_access_key( + access_key=dummy_access_key, + login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), + ) ) - self.assertEqual(jwt_response["keyId"], "U2CuCPuJgPWHGB5P4GmfbuPGhGVm") - self.assertEqual(jwt_response["projectId"], "P2CuC9yv2UGtGI1o84gCZEb9qEQW") + assert jwt_response["keyId"] == EXPECTED_USER_ID + assert jwt_response["projectId"] == EXPECTED_PROJECT_ID + assert_http_called( + mock_post, + descope_client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:dummy access key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginOptions": {"customClaims": {"k1": "v1"}}}, + follow_redirects=False, + ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:dummy access key", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"loginOptions": {"customClaims": {"k1": "v1"}}}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) + # ------------------------------------------------------------------ + # JWT validation leeway + # ------------------------------------------------------------------ - def test_jwt_validation_leeway(self): - # Note: I set here negative leeway just for setting the check time results to be in the "past" - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" + async def test_jwt_validation_leeway(self, client_factory): + # Negative leeway forces even far-future tokens to appear expired min_int = -sys.maxsize - 1 - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", - }, - jwt_validation_leeway=min_int, - ) + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, jwt_validation_leeway=min_int) - with self.assertRaises(AuthException) as cm: - client.validate_session(valid_jwt_token) - self.assertEqual(cm.exception.status_code, 400) - self.assertEqual( - cm.exception.error_message, - "Received Invalid token (nbf in future) during jwt validation. Error can be due to time glitch (between machines), try to set the jwt_validation_leeway parameter (in DescopeClient) to higher value than 5sec which is the default", - ) + with pytest.raises(AuthException) as exc_info: + client.validate_session(VALID_REFRESH_TOKEN) + assert exc_info.value.status_code == 400 + assert exc_info.value.error_message is not None + assert "nbf in future" in exc_info.value.error_message + + # ------------------------------------------------------------------ + # select_tenant + # ------------------------------------------------------------------ + + async def test_select_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - def test_select_tenant(self): - client = DescopeClient( - self.dummy_project_id, - { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", - "kty": "EC", - "use": "sig", - "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", - "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", + data = json.loads( + """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + ) + resp = make_response(data) + with client.mock_post(resp) as mock_post: + await client.invoke(client.select_tenant("t1", VALID_REFRESH_TOKEN)) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.select_tenant_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{VALID_REFRESH_TOKEN}", + "x-descope-project-id": PROJECT_ID, }, + params=None, + json={"tenant": "t1"}, + follow_redirects=False, ) - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYyVUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ.mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOxICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" + # ------------------------------------------------------------------ + # auth_management_key header propagation (sync-only: uses otp) + # ------------------------------------------------------------------ - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - client.select_tenant("t1", valid_jwt_token) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.select_tenant_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{valid_jwt_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "tenant": "t1", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) + async def test_auth_management_key_with_functions(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("otp not available on DescopeClientAsync") - def test_auth_management_key_with_functions(self): - """Test auth_management_key with functions that require and don't require refresh tokens""" auth_mgmt_key = "test-auth-mgmt-key" # Test 1: Direct auth_management_key setting (without refresh token) - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - auth_management_key=auth_mgmt_key, - ) + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) with patch("httpx.post") as mock_post: my_mock_response = mock.Mock() @@ -833,8 +755,8 @@ def test_auth_management_key_with_functions(self): f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", headers={ **common.default_headers, - "x-descope-project-id": self.dummy_project_id, - "Authorization": f"Bearer {self.dummy_project_id}:{auth_mgmt_key}", + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{auth_mgmt_key}", }, json={ "loginId": "test@example.com", @@ -850,7 +772,7 @@ def test_auth_management_key_with_functions(self): # Test 2: Environment variable auth_management_key setting env_auth_mgmt_key = "env-auth-mgmt-key" with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_env = DescopeClient(self.dummy_project_id, self.public_key_dict) + client_env = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) with patch("httpx.post") as mock_post: my_mock_response = mock.Mock() @@ -864,8 +786,8 @@ def test_auth_management_key_with_functions(self): f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", headers={ **common.default_headers, - "x-descope-project-id": self.dummy_project_id, - "Authorization": f"Bearer {self.dummy_project_id}:{env_auth_mgmt_key}", + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{env_auth_mgmt_key}", }, json={ "loginId": "test@example.com", @@ -881,10 +803,8 @@ def test_auth_management_key_with_functions(self): # Test 3: Direct parameter takes priority over environment variable direct_auth_mgmt_key = "direct-auth-mgmt-key" with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_priority = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - auth_management_key=direct_auth_mgmt_key, + client_priority = client_factory.make( + PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=direct_auth_mgmt_key ) with patch("httpx.post") as mock_post: @@ -899,8 +819,8 @@ def test_auth_management_key_with_functions(self): f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", headers={ **common.default_headers, - "x-descope-project-id": self.dummy_project_id, - "Authorization": f"Bearer {self.dummy_project_id}:{direct_auth_mgmt_key}", + "x-descope-project-id": PROJECT_ID, + "Authorization": f"Bearer {PROJECT_ID}:{direct_auth_mgmt_key}", }, json={ "loginId": "test@example.com", @@ -913,13 +833,12 @@ def test_auth_management_key_with_functions(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_auth_management_key_with_refresh_token(self): + async def test_auth_management_key_with_refresh_token(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("otp not available on DescopeClientAsync") + auth_mgmt_key = "test-auth-mgmt-key" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - auth_management_key=auth_mgmt_key, - ) + client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) # Test with refresh token function refresh_token = "test_refresh_token" @@ -935,8 +854,8 @@ def test_auth_management_key_with_refresh_token(self): f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", headers={ **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}:{auth_mgmt_key}", - "x-descope-project-id": self.dummy_project_id, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}:{auth_mgmt_key}", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "old@example.com", @@ -950,8 +869,8 @@ def test_auth_management_key_with_refresh_token(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - # Test without auth_management_key for comparison - client_no_auth = DescopeClient(self.dummy_project_id, self.public_key_dict) + # Without auth_management_key — refresh token only in Authorization + client_no_auth = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) with patch("httpx.post") as mock_post: my_mock_response = mock.Mock() my_mock_response.is_success = True @@ -964,8 +883,8 @@ def test_auth_management_key_with_refresh_token(self): f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", headers={ **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "old@example.com", @@ -979,79 +898,64 @@ def test_auth_management_key_with_refresh_token(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_base_url_setting(self): - """Test that base_url parameter is correctly set in DescopeClient""" + # ------------------------------------------------------------------ + # base_url parameter + # ------------------------------------------------------------------ + + async def test_base_url_setting(self, client_factory): custom_base_url = "https://api.use1.descope.com" - client = DescopeClient( - project_id=self.dummy_project_id, - base_url=custom_base_url, - public_key=self.public_key_dict, - ) + client = client_factory.make(PROJECT_ID, base_url=custom_base_url, public_key=PUBLIC_KEY_DICT) - # Verify that the base_url is set in the auth HTTP client - self.assertEqual(client._auth.http_client.base_url, custom_base_url) + # Auth HTTP client base_url is available on both sync and async + assert client._auth.http_client.base_url == custom_base_url - # Verify that the base_url is set in the mgmt HTTP client - self.assertEqual(client._mgmt._http.base_url, custom_base_url) + # Management HTTP client is sync-only + if client_factory.mode == "sync": + assert client._mgmt._http.base_url == custom_base_url - def test_base_url_none(self): - """Test that base_url=None uses default base URL from environment or project ID""" - # When base_url is None, it should use DESCOPE_BASE_URI env var or computed default - client = DescopeClient( - project_id=self.dummy_project_id, - base_url=None, - public_key=self.public_key_dict, - ) + async def test_base_url_none(self, client_factory): + client = client_factory.make(PROJECT_ID, base_url=None, public_key=PUBLIC_KEY_DICT) expected_base_url = common.DEFAULT_BASE_URL - self.assertEqual(client._auth.http_client.base_url, expected_base_url) - self.assertEqual(client._mgmt._http.base_url, expected_base_url) - - def test_verbose_mode_disabled_by_default(self): - """Test that verbose mode is disabled by default.""" - client = DescopeClient( - project_id=self.dummy_project_id, - public_key=self.public_key_dict, - ) + assert client._auth.http_client.base_url == expected_base_url + + if client_factory.mode == "sync": + assert client._mgmt._http.base_url == expected_base_url + + # ------------------------------------------------------------------ + # Verbose mode + # ------------------------------------------------------------------ + + async def test_verbose_mode_disabled_by_default(self, client_factory): + client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT) assert client.get_last_response() is None - def test_verbose_mode_enabled(self): - """Test that verbose mode can be enabled.""" - client = DescopeClient( - project_id=self.dummy_project_id, - public_key=self.public_key_dict, - verbose=True, - ) + async def test_verbose_mode_enabled(self, client_factory): + client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT, verbose=True) # Just verify it doesn't error when enabled assert client.get_last_response() is None # No requests made yet - @patch("httpx.post") - def test_verbose_mode_captures_mgmt_response(self, mock_post): - """Test that management API responses are captured in verbose mode.""" + async def test_verbose_mode_captures_mgmt_response(self, client_factory): + if client_factory.mode != "sync": + pytest.skip("mgmt not available on DescopeClientAsync") + mock_response = mock.Mock() mock_response.is_success = True mock_response.json.return_value = {"user": {"id": "u1", "loginIds": ["test@example.com"]}} mock_response.headers = {"cf-ray": "mgmt-ray-123", "x-request-id": "req-456"} mock_response.status_code = 200 - mock_post.return_value = mock_response - client = DescopeClient( - project_id=self.dummy_project_id, - public_key=self.public_key_dict, - management_key="test-mgmt-key", - verbose=True, - ) - - # Make a management API call - client.mgmt.user.create(login_id="test@example.com") + with patch("httpx.post", return_value=mock_response): + client = client_factory.make( + PROJECT_ID, + public_key=PUBLIC_KEY_DICT, + management_key="test-mgmt-key", + verbose=True, + ) + client.mgmt.user.create(login_id="test@example.com") - # Verify response was captured last_resp = client.get_last_response() assert last_resp is not None assert last_resp["user"]["id"] == "u1" assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" assert last_resp.status_code == 200 - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_totp.py b/tests/test_totp.py index 9299b100d..e2ea1c55a 100644 --- a/tests/test_totp.py +++ b/tests/test_totp.py @@ -1,197 +1,167 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth -from descope.authmethod.totp import TOTP # noqa: F401 -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1, LoginOptions -from tests.testutils import SSLMatcher +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- -class TestTOTP(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", - } - def test_sign_up(self): +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestTOTP: + # ------------------------------------------------------------------ + # sign_up + # ------------------------------------------------------------------ + + async def test_sign_up(self, client_factory): signup_user_details = { "username": "jhon", "name": "john", "phone": "972525555555", "email": "dummy@dummy.com", } - - totp = TOTP( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - totp.sign_up, - "", - signup_user_details, - ) - - self.assertRaises( - AuthException, - totp.sign_up, - None, - signup_user_details, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - totp.sign_up, - "dummy@dummy.com", - signup_user_details, - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(totp.sign_up("dummy@dummy.com", signup_user_details)) - - def test_sign_in(self): - totp = TOTP( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up("", signup_user_details)) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up(None, signup_user_details)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) + + # Success + data = {"provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01"} + with client.mock_post(make_response(data)): + result = await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) + assert result is not None + + # ------------------------------------------------------------------ + # sign_in_code + # ------------------------------------------------------------------ + + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = "dummy refresh token" + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code(None, "1234")) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("", "1234")) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", None)) + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) + + # Success + MFA-without-refresh check + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, ) - - # Test failed flows - self.assertRaises(AuthException, totp.sign_in_code, None, "1234") - self.assertRaises(AuthException, totp.sign_in_code, "", "1234") - self.assertRaises(AuthException, totp.sign_in_code, "dummy@dummy.com", None) - self.assertRaises(AuthException, totp.sign_in_code, "dummy@dummy.com", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, totp.sign_in_code, "dummy@dummy.com", "1234") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - self.assertIsNotNone(totp.sign_in_code("dummy@dummy.com", "1234")) - self.assertRaises( - AuthException, - totp.sign_in_code, - "dummy@dummy.com", - "code", - LoginOptions(mfa=True), - ) - - # Validate refresh token used while provided - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" + with client.mock_post(success_resp): + result = await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) + assert result is not None + # MFA stepup requires a refresh token — omitting it must raise + with pytest.raises(AuthException): + await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "code", LoginOptions(mfa=True))) + + # Verify refresh token propagates correctly into the request + with client.mock_post(success_resp) as mock_post: + await client.invoke( + client.totp.sign_in_code( + "dummy@dummy.com", + "1234", + LoginOptions(stepup=True), + refresh_token=refresh_token, + ) ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - refresh_token = "dummy refresh token" - totp.sign_in_code( - "dummy@dummy.com", - "1234", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_totp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "code": "1234", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_totp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "code": "1234", + "loginOptions": { + "stepup": True, + "customClaims": None, + "mfa": False, }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_update_user(self): - totp = TOTP( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + }, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, totp.update_user, None, "") - self.assertRaises(AuthException, totp.update_user, "", "") - self.assertRaises(AuthException, totp.update_user, "dummy@dummy.com", None) - self.assertRaises(AuthException, totp.update_user, "dummy@dummy.com", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - totp.update_user, - "dummy@dummy.com", - "dummy refresh token", - ) + # ------------------------------------------------------------------ + # update_user + # ------------------------------------------------------------------ + + async def test_update_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + valid_refresh_token = VALID_REFRESH_TOKEN + valid_response = { + "provisioningURL": "http://dummy.com", + "image": "imagedata", + "key": "k01", + "error": "", + } - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - valid_response = json.loads( - """{ "provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01", "error": "" }""" - ) - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - res = totp.update_user("dummy@dummy.com", valid_jwt_token) - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_totp_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{valid_jwt_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"loginId": "dummy@dummy.com"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user(None, "")) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("", "")) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", None)) + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.totp.update_user("dummy@dummy.com", "dummy refresh token")) + + # Success + payload assertion + with client.mock_post(make_response(valid_response)) as mock_post: + res = await client.invoke(client.totp.update_user("dummy@dummy.com", valid_refresh_token)) + assert res == valid_response + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_totp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{valid_refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com"}, + follow_redirects=False, + ) diff --git a/tests/testutils.py b/tests/testutils.py index f46818aa1..7fe251023 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -1,5 +1,46 @@ from ssl import SSLContext +# --------------------------------------------------------------------------- +# Canonical test key + JWTs — all signed with PUBLIC_KEY_DICT (kid=P2Cu…) +# --------------------------------------------------------------------------- + +PUBLIC_KEY_DICT = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CuC9yv2UGtGI1o84gCZEb9qEQW", + "kty": "EC", + "use": "sig", + "x": "DCjjyS7blnEmenLyJVwmH6yMnp7MlEggfk1kLtOv_Khtpps_Mq4K9brqsCwQhGUP", + "y": "xKy4IQ2FaLEzrrl1KE5mKbioLhj1prYFk1itdTOr6Xpy1fgq86kC7v-Y2F2vpcDc", +} + +# drn=DSR, exp=2264443061 (far future) +VALID_REFRESH_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" + "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" + ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" + "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" +) + +# drn=DS, exp=2493061415 (far future) +VALID_SESSION_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" + "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" + ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" + "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" +) + +# drn=DS, exp=1659644298 (past — use for expiry tests) +EXPIRED_SESSION_TOKEN = ( + "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" + ".eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3" + "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" + ".wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvD" + "NgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" +) + class SSLMatcher: """Matcher for the `verify=` kwarg passed to httpx.* calls in tests. From 95e7f8b7904149f5a25d6244c841514623473f12 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:25:09 +0300 Subject: [PATCH 10/17] good --- tests/test_descope_client_parity.py | 1009 --------------------------- tests/test_totp_parity.py | 219 ------ 2 files changed, 1228 deletions(-) delete mode 100644 tests/test_descope_client_parity.py delete mode 100644 tests/test_totp_parity.py diff --git a/tests/test_descope_client_parity.py b/tests/test_descope_client_parity.py deleted file mode 100644 index 8027a237e..000000000 --- a/tests/test_descope_client_parity.py +++ /dev/null @@ -1,1009 +0,0 @@ -""" -Parity port of test_descope_client.py using the unified sync/async fixture infrastructure. - -Structure mirrors the original: one class, one test method per feature. Each method -runs twice — once for the sync DescopeClient and once for DescopeClientAsync — via -pytest's parametrised ``descope_client`` / ``client_factory`` fixtures from conftest. - -Tests that exercise surfaces not yet ported to DescopeClientAsync (mgmt, otp, oauth…) -call ``pytest.skip()`` in async mode so the original assertions are preserved verbatim. -""" - -from __future__ import annotations - -import json -import sys -from copy import deepcopy -from unittest import mock -from unittest.mock import patch - -import pytest - -from descope import ( - API_RATE_LIMIT_RETRY_AFTER_HEADER, - ERROR_TYPE_API_RATE_LIMIT, - SESSION_COOKIE_NAME, - AccessKeyLoginOptions, - AuthException, - RateLimitException, -) -from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, - SESSION_TOKEN_NAME, - DeliveryMethod, - EndpointsV1, -) -from tests.conftest import PROJECT_ID, PUBLIC_KEY_DICT, make_response -from tests.testutils import SSLMatcher - -from . import common - -# --------------------------------------------------------------------------- -# Module-level constants -# --------------------------------------------------------------------------- - -PUBLIC_KEY_STR = json.dumps(PUBLIC_KEY_DICT) - -# The original setUp public_key_dict (kid=2Bt5…) used by a handful of tests -DUMMY_PUBLIC_KEY_DICT = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", -} - -# JWT tokens (all signed with kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW) -VALID_REFRESH_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" - "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" - ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" - "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" -) - -VALID_SESSION_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" - "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" - ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" - "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" -) - -# drn=DS, exp=1659644298 (past) -EXPIRED_SESSION_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEUyIsImV4cCI6MTY1OTY0NDI5OCwiaWF0IjoxNjU5NjQ0Mjk3LCJpc3MiOiJQMkN1Qzl5djJVR3" - "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" - ".wBuOnIQI_z3SXOszqsWCg8ilOPdE5ruWYHA3jkaeQ3uX9hWgCTd69paFajc-xdMYbqlIF7JHji7T9oVmkCUJvD" - "NgRZRZO9boMFANPyXitLOK4aX3VZpMJBpFxdrWV3GE" -) - -EXPECTED_USER_ID = "U2CuCPuJgPWHGB5P4GmfbuPGhGVm" -EXPECTED_PROJECT_ID = "P2CuC9yv2UGtGI1o84gCZEb9qEQW" - -# Tokens ported from test_descope_client.py that must fail validate_session -_INVALID_HEADER_TOKEN = ( - "AyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9" - ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImR1bW15In0" - ".Bcz3xSxEcxgBSZOzqrTvKnb9-u45W-RlAbHSBL6E8zo2yJ9SYfODphdZ8tP5ARNTvFSPj2wgyu1SeiZWoGGP" - "HPNMt4p65tPeVf5W8--d2aKXCc4KvAOOK3B_Cvjy_TO8" -) -_MISSING_KID_TOKEN = ( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCIsImFhYSI6IjMyYjNkYTUyNzdiMTQyYzdlMjRmZGYwZWYwOWUwOTE5In0" - ".eyJleHAiOjE5ODEzOTgxMTF9" - ".GQ3nLYT4XWZWezJ1tRV6ET0ibRvpEipeo6RCuaCQBdP67yu98vtmUvusBElDYVzRxGRtw5d20HICyo0_3Ekb0euUP" - "3iTupgS3EU1DJMeAaJQgOwhdQnQcJFkOpASLKWh" -) -_INVALID_PAYLOAD_TOKEN = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" - - -# --------------------------------------------------------------------------- -# Helper: mode-aware HTTP call assertion -# --------------------------------------------------------------------------- - - -def assert_http_called(mock_http, mode, url, **kwargs): - """Assert the patched HTTP mock was called with the given arguments. - - In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode - they are set on the ``httpx.AsyncClient`` constructor and absent from each call. - This helper injects them automatically for sync so test bodies stay identical. - """ - if mode == "sync": - kwargs.setdefault("verify", SSLMatcher()) - kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) - mock_http.assert_called_with(url, **kwargs) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestDescopeClient: - # ------------------------------------------------------------------ - # Construction validation - # ------------------------------------------------------------------ - - async def test_descope_client(self, client_factory): - with pytest.raises(AuthException): - client_factory.make(None, "dummy") - with pytest.raises(AuthException): - client_factory.make("", "dummy") - - with patch("os.getenv") as mock_getenv: - mock_getenv.return_value = "" - with pytest.raises(AuthException): - client_factory.make(None, "dummy") - - assert client_factory.make(PROJECT_ID, None) is not None - assert client_factory.make(PROJECT_ID, "") is not None - with pytest.raises(AuthException): - client_factory.make(PROJECT_ID, "not dict object") - assert client_factory.make(PROJECT_ID, PUBLIC_KEY_STR) is not None - - async def test_project_id_from_env_without_env(self, client_factory): - with patch.dict("os.environ", {"DESCOPE_PROJECT_ID": ""}): - with pytest.raises(AuthException): - client_factory.make("") - - # ------------------------------------------------------------------ - # Management client (sync-only) - # ------------------------------------------------------------------ - - async def test_mgmt(self, descope_client): - if descope_client.mode != "sync": - pytest.skip("mgmt not available on DescopeClientAsync") - - # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set - with pytest.raises(AuthException): - _ = descope_client.mgmt.tenant - with pytest.raises(AuthException): - _ = descope_client.mgmt.sso_application - with pytest.raises(AuthException): - _ = descope_client.mgmt.user - with pytest.raises(AuthException): - _ = descope_client.mgmt.access_key - with pytest.raises(AuthException): - _ = descope_client.mgmt.sso - with pytest.raises(AuthException): - _ = descope_client.mgmt.jwt - with pytest.raises(AuthException): - _ = descope_client.mgmt.permission - with pytest.raises(AuthException): - _ = descope_client.mgmt.role - with pytest.raises(AuthException): - _ = descope_client.mgmt.group - with pytest.raises(AuthException): - _ = descope_client.mgmt.flow - with pytest.raises(AuthException): - _ = descope_client.mgmt.audit - with pytest.raises(AuthException): - _ = descope_client.mgmt.authz - with pytest.raises(AuthException): - _ = descope_client.mgmt.fga - with pytest.raises(AuthException): - _ = descope_client.mgmt.project - with pytest.raises(AuthException): - _ = descope_client.mgmt.outbound_application - - # Validate that outbound_application_by_token doesn't require mgmt key - try: - _ = descope_client.mgmt.outbound_application_by_token - except AuthException: - pytest.fail("failed to initiate outbound_application_by_token without management key") - - # ------------------------------------------------------------------ - # logout / logout_all - # ------------------------------------------------------------------ - - async def test_logout(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout(None)) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout("")) - - with descope_client.mock_post(make_response(status=200)): - assert await descope_client.invoke(descope_client.logout("")) is not None - - async def test_logout_all(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout_all(None)) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.logout_all("")) - - with descope_client.mock_post(make_response(status=200)): - assert await descope_client.invoke(descope_client.logout_all("")) is not None - - # ------------------------------------------------------------------ - # me - # ------------------------------------------------------------------ - - async def test_me(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.me(None)) - - with descope_client.mock_get(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.me("")) - - data = json.loads("""{"name": "Testy McTester", "email": "testy@tester.com"}""") - with descope_client.mock_get(make_response(data)) as mock_get: - user_response = await descope_client.invoke(descope_client.me("")) - assert user_response is not None - assert data["name"] == user_response["name"] - assert_http_called( - mock_get, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.me_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=None, - params=None, - ) - - # ------------------------------------------------------------------ - # my_tenants - # ------------------------------------------------------------------ - - async def test_my_tenants(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants(None)) - - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("")) - - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("", True, ["a"])) - - with descope_client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.my_tenants("", True)) - - data = json.loads("""{"tenants": [{"id": "tenant_id", "name": "tenant_name"}]}""") - with descope_client.mock_post(make_response(data)) as mock_post: - tenant_response = await descope_client.invoke(descope_client.my_tenants("", False, ["a"])) - assert tenant_response is not None - assert data["tenants"][0]["name"] == tenant_response["tenants"][0]["name"] - assert_http_called( - mock_post, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.my_tenants_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - json={"dct": False, "ids": ["a"]}, - follow_redirects=False, - params=None, - ) - - # ------------------------------------------------------------------ - # history - # ------------------------------------------------------------------ - - async def test_history(self, descope_client): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.history(None)) - - with descope_client.mock_get(make_response(status=500)): - with pytest.raises(AuthException): - await descope_client.invoke(descope_client.history("")) - - data = json.loads( - """ - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32 - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23 - } - ] - """ - ) - with descope_client.mock_get(make_response(data)) as mock_get: - user_response = await descope_client.invoke(descope_client.history("")) - assert user_response is not None - assert data == user_response - assert_http_called( - mock_get, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.history_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}", - "x-descope-project-id": PROJECT_ID, - }, - follow_redirects=None, - params=None, - ) - - # ------------------------------------------------------------------ - # validate_session — pure-CPU helper (no IO) - # ------------------------------------------------------------------ - - async def test_validate_session(self, client_factory): - # Client with the 2Bt5 key (matching the kid in _INVALID_PAYLOAD_TOKEN) - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - - with pytest.raises(AuthException): - client.validate_session(_MISSING_KID_TOKEN) - with pytest.raises(AuthException): - client.validate_session(_INVALID_HEADER_TOKEN) - with pytest.raises(AuthException): - client.validate_session(_INVALID_PAYLOAD_TOKEN) - - # None key client + None token - client4 = client_factory.make(PROJECT_ID, None) - with pytest.raises(AuthException): - client4.validate_session(None) - - async def test_validate_session_response_structure(self, descope_client): - result = descope_client.validate_session(VALID_SESSION_TOKEN) - assert result == { - "drn": "DS", - "exp": 2493061415, - "iat": 1659643061, - "iss": EXPECTED_PROJECT_ID, - "sub": EXPECTED_USER_ID, - "jwt": VALID_SESSION_TOKEN, - "permissions": [], - "roles": [], - "tenants": {}, - "projectId": EXPECTED_PROJECT_ID, - "userId": EXPECTED_USER_ID, - "sessionToken": { - "drn": "DS", - "exp": 2493061415, - "iat": 1659643061, - "iss": EXPECTED_PROJECT_ID, - "sub": EXPECTED_USER_ID, - "jwt": VALID_SESSION_TOKEN, - }, - } - - async def test_validate_session_valid_tokens(self, client_factory): - # Client with P2Cu key preloaded - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - dummy_refresh_token = "refresh" - valid_jwt_token = VALID_REFRESH_TOKEN # far-future DSR token, P2Cu kid - - # Valid token validates locally — no network needed - client.validate_session(valid_jwt_token) - - assert ( - await client.invoke(client.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) is not None - ) - - # Key id cannot be found — key fetch returns wrong kid - client2 = client_factory.make(PROJECT_ID, None) - with patch("httpx.get") as mock_request: - fake_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) - fake_key["kid"] = "dummy_kid" - mock_request.return_value.text = json.dumps([fake_key]) - mock_request.return_value.is_success = True - with pytest.raises(AuthException): - await client2.invoke(client2.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # Key fetch returns unparsable key - client3 = client_factory.make(PROJECT_ID, None) - with patch("httpx.get") as mock_request: - mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" - mock_request.return_value.is_success = True - with pytest.raises(AuthException): - await client3.invoke(client3.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # header_alg != key[alg] - bad_alg_key = deepcopy(DUMMY_PUBLIC_KEY_DICT) - bad_alg_key["alg"] = "ES521" - client4 = client_factory.make(PROJECT_ID, bad_alg_key) - with patch("httpx.get") as mock_request: - mock_request.return_value.text = """[{"kid": "dummy_kid"}]""" - mock_request.return_value.is_success = True - with pytest.raises(AuthException): - await client4.invoke(client4.validate_and_refresh_session(valid_jwt_token, dummy_refresh_token)) - - # Both session_token and refresh_token are None - client4b = client_factory.make(PROJECT_ID, None) - with pytest.raises(AuthException): - await client4b.invoke(client4b.validate_and_refresh_session(None, None)) - - # Expired session triggers refresh; refreshed token is also expired → fails - expired_jwt_token = EXPIRED_SESSION_TOKEN - valid_refresh_for_expire_test = valid_jwt_token - with patch("httpx.get") as mock_request: - mock_request.return_value.cookies = {SESSION_COOKIE_NAME: expired_jwt_token} - mock_request.return_value.is_success = True - with pytest.raises(AuthException): - await client3.invoke( - client3.validate_and_refresh_session(expired_jwt_token, valid_refresh_for_expire_test) - ) - - # ------------------------------------------------------------------ - # Exception object shapes (no client needed) - # ------------------------------------------------------------------ - - def test_exception_object(self): - ex = AuthException(401, "dummy-type", "dummy error message") - assert str(ex) is not None - assert repr(ex) is not None - assert ex.status_code == 401 - assert ex.error_type == "dummy-type" - assert ex.error_message == "dummy error message" - - def test_api_rate_limit_exception_object(self): - ex = RateLimitException( - 429, - ERROR_TYPE_API_RATE_LIMIT, - "API rate limit exceeded description", - "API rate limit exceeded", - {API_RATE_LIMIT_RETRY_AFTER_HEADER: "9"}, - ) - assert str(ex) is not None - assert repr(ex) is not None - assert ex.status_code == 429 - assert ex.error_type == ERROR_TYPE_API_RATE_LIMIT - assert ex.error_description == "API rate limit exceeded description" - assert ex.error_message == "API rate limit exceeded" - assert ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, "") == "9" - - # ------------------------------------------------------------------ - # Expired token + refresh flows - # ------------------------------------------------------------------ - - async def test_expired_token(self, client_factory): - # expired DS token (kid=P2Cu, exp=1657798328 — past) - expired_jwt_token = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9" - ".eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg5NzI4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk4MzI4LCJpYXQiOjE2NTc3OTc3MjgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9" - ".i-JoPoYmXl3jeLTARvYnInBiRdTT4uHZ3X3xu_n1dhUb1Qy_gqK7Ru8ErYXeENdfPOe4mjShc_HsVyb5PjE2LMFmb58WR8wixtn0R-u_MqTpuI_422Dk6hMRjTFEVRWu" - ) - dummy_refresh_token = "dummy refresh token" - - # Client with P2Cu key (same kid the validate tokens use in refresh path) - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Fail flow: key is preloaded so validate_session raises due to expiration - with patch("httpx.get") as mock_request: - mock_request.return_value.is_success = False - with pytest.raises(AuthException): - client.validate_session(expired_jwt_token) - - with patch("httpx.get") as mock_request: - mock_request.return_value.cookies = {"aaa": "aaa"} - mock_request.return_value.is_success = True - with pytest.raises(AuthException): - client.validate_session(expired_jwt_token) - - # Fail flow: jwt.get_unverified_header returns {} (no kid) - dummy_session_token = "dummy session token" - # dummy_client has the 2Bt5 key; EXPIRED_SESSION_TOKEN uses P2Cu — key not loaded - dummy_client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - with patch("jwt.get_unverified_header") as mock_jwt_get_unverified_header: - mock_jwt_get_unverified_header.return_value = {} - with pytest.raises(AuthException): - await dummy_client.invoke( - dummy_client.validate_and_refresh_session(dummy_session_token, dummy_refresh_token) - ) - - # Success flow: expired token → POST refresh → returns valid new session token - new_session_token = VALID_SESSION_TOKEN - valid_refresh_token = VALID_REFRESH_TOKEN - expired_token = EXPIRED_SESSION_TOKEN - resp = make_response({"sessionJwt": new_session_token}, cookies={}) - with client.mock_post(resp): - # Refresh because of expiration - result = await client.invoke(client.validate_and_refresh_session(expired_token, valid_refresh_token)) - new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] - assert new_session_token_from_request == new_session_token, "Failed to refresh token" - - # Refresh explicitly - result = await client.invoke(client.refresh_session(valid_refresh_token)) - new_session_token_from_request = result[SESSION_TOKEN_NAME]["jwt"] - assert new_session_token_from_request == new_session_token, "Failed to refresh token" - - # Fail flow: refreshed token is also expired → AuthException - # dummy_client has P2Cu key; expired_jwt_token (kid=2Bt5) is NOT preloaded → triggers - # JWKS fetch via httpx.get; mock returns garbage JSON → AuthException - expired_jwt_token2 = EXPIRED_SESSION_TOKEN - valid_refresh_token2 = VALID_REFRESH_TOKEN - new_refreshed_token = expired_jwt_token2 - with patch("httpx.get") as mock_request: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"sessionJwt": new_refreshed_token} - mock_request.return_value = my_mock_response - mock_request.return_value.cookies = {} - with pytest.raises(AuthException): - await dummy_client.invoke( - dummy_client.validate_and_refresh_session(expired_jwt_token2, valid_refresh_token2) - ) - - # ------------------------------------------------------------------ - # Public key loading errors - # ------------------------------------------------------------------ - - async def test_public_key_load(self, client_factory): - # Test key without kty property - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key.pop("kty") - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - # Test key without kid property - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key.pop("kid") - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - # Test key with unknown algorithm - invalid_public_key = deepcopy(PUBLIC_KEY_DICT) - invalid_public_key["alg"] = "unknown algorithm" - with pytest.raises(AuthException) as exc_info: - client_factory.make(PROJECT_ID, invalid_public_key) - assert exc_info.value.status_code == 500 - - # ------------------------------------------------------------------ - # Client property surface - # ------------------------------------------------------------------ - - async def test_client_properties(self, descope_client): - # totp is available on both sync and async clients - assert descope_client.totp is not None, "Empty totp object" - - # All other auth-method properties are sync-only - if descope_client.mode != "sync": - return - assert descope_client.magiclink is not None, "Empty Magiclink object" - assert descope_client.otp is not None, "Empty otp object" - assert descope_client.oauth is not None, "Empty oauth object" - assert descope_client.saml is not None, "Empty saml object" - assert descope_client.sso is not None, "Empty saml object" - assert descope_client.webauthn is not None, "Empty webauthN object" - - # ------------------------------------------------------------------ - # Permission / role helpers — pure-CPU - # ------------------------------------------------------------------ - - async def test_validate_permissions(self, descope_client): - jwt_response = {} - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False - - jwt_response = {"permissions": []} - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False - assert descope_client.validate_permissions(jwt_response, []) is True - - jwt_response = {"permissions": ["Perm 1"]} - assert descope_client.validate_permissions(jwt_response, "Perm 1") is True - assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is True - assert descope_client.validate_permissions(jwt_response, ["Perm 2"]) is False - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {"permissions": "Perm 1"}}} - assert descope_client.validate_tenant_permissions(jwt_response, "t1", []) is True - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1"]) is True - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 2"]) is False - assert descope_client.validate_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False - assert descope_client.validate_tenant_permissions(jwt_response, "t2", []) is False - - async def test_get_matched_permissions(self, descope_client): - jwt_response = {} - assert descope_client.get_matched_permissions(jwt_response, []) == [] - - jwt_response = {"permissions": []} - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == [] - - jwt_response = {"permissions": ["Perm 1", "Perm 2"]} - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1"]) == ["Perm 1"] - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2"]) == ["Perm 1", "Perm 2"] - assert descope_client.get_matched_permissions(jwt_response, ["Perm 1", "Perm 2", "Perm 3"]) == [ - "Perm 1", - "Perm 2", - ] - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == [] - - jwt_response = {"tenants": {"t1": {"permissions": ["Perm 1", "Perm 2"]}}} - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1"]) == ["Perm 1"] - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2"]) == [ - "Perm 1", - "Perm 2", - ] - assert descope_client.get_matched_tenant_permissions(jwt_response, "t1", ["Perm 1", "Perm 2", "Perm 3"]) == [ - "Perm 1", - "Perm 2", - ] - - async def test_validate_roles(self, descope_client): - jwt_response = {} - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False - - jwt_response = {"roles": []} - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is False - assert descope_client.validate_roles(jwt_response, []) is True - - jwt_response = {"roles": ["Role 1"]} - assert descope_client.validate_roles(jwt_response, "Role 1") is True - assert descope_client.validate_roles(jwt_response, ["Role 1"]) is True - assert descope_client.validate_roles(jwt_response, ["Role 2"]) is False - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 2"]) is False - - jwt_response = {"tenants": {"t1": {"roles": "Role 1"}}} - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1"]) is True - assert descope_client.validate_tenant_roles(jwt_response, "t1", []) is True - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 2"]) is False - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) is False - assert descope_client.validate_tenant_roles(jwt_response, "t1", ["Perm 1", "Perm 2"]) is False - - async def test_get_matched_roles(self, descope_client): - jwt_response = {} - assert descope_client.get_matched_roles(jwt_response, []) == [] - - jwt_response = {"roles": []} - assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == [] - - jwt_response = {"roles": ["Role 1", "Role 2"]} - assert descope_client.get_matched_roles(jwt_response, ["Role 1"]) == ["Role 1"] - assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] - assert descope_client.get_matched_roles(jwt_response, ["Role 1", "Role 2", "Role 3"]) == ["Role 1", "Role 2"] - - # Tenant level - jwt_response = {"tenants": {}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] - - jwt_response = {"tenants": {"t1": {}}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == [] - - jwt_response = {"tenants": {"t1": {"roles": ["Role 1", "Role 2"]}}} - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1"]) == ["Role 1"] - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2"]) == ["Role 1", "Role 2"] - assert descope_client.get_matched_tenant_roles(jwt_response, "t1", ["Role 1", "Role 2", "Role 3"]) == [ - "Role 1", - "Role 2", - ] - - # ------------------------------------------------------------------ - # exchange_access_key - # ------------------------------------------------------------------ - - async def test_exchange_access_key_empty_param(self, descope_client): - with pytest.raises(AuthException) as exc_info: - await descope_client.invoke(descope_client.exchange_access_key("")) - assert exc_info.value.status_code == 400 - - async def test_exchange_access_key(self, descope_client): - dummy_access_key = "dummy access key" - resp = make_response({"sessionJwt": VALID_REFRESH_TOKEN}) - with descope_client.mock_post(resp) as mock_post: - jwt_response = await descope_client.invoke( - descope_client.exchange_access_key( - access_key=dummy_access_key, - login_options=AccessKeyLoginOptions(custom_claims={"k1": "v1"}), - ) - ) - assert jwt_response["keyId"] == EXPECTED_USER_ID - assert jwt_response["projectId"] == EXPECTED_PROJECT_ID - assert_http_called( - mock_post, - descope_client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.exchange_auth_access_key_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:dummy access key", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginOptions": {"customClaims": {"k1": "v1"}}}, - follow_redirects=False, - ) - - # ------------------------------------------------------------------ - # JWT validation leeway - # ------------------------------------------------------------------ - - async def test_jwt_validation_leeway(self, client_factory): - # Negative leeway forces even far-future tokens to appear expired - min_int = -sys.maxsize - 1 - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, jwt_validation_leeway=min_int) - - with pytest.raises(AuthException) as exc_info: - client.validate_session(VALID_REFRESH_TOKEN) - assert exc_info.value.status_code == 400 - assert exc_info.value.error_message is not None - assert "nbf in future" in exc_info.value.error_message - - # ------------------------------------------------------------------ - # select_tenant - # ------------------------------------------------------------------ - - async def test_select_tenant(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - resp = make_response(data) - with client.mock_post(resp) as mock_post: - await client.invoke(client.select_tenant("t1", VALID_REFRESH_TOKEN)) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.select_tenant_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{VALID_REFRESH_TOKEN}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"tenant": "t1"}, - follow_redirects=False, - ) - - # ------------------------------------------------------------------ - # auth_management_key header propagation (sync-only: uses otp) - # ------------------------------------------------------------------ - - async def test_auth_management_key_with_functions(self, client_factory): - if client_factory.mode != "sync": - pytest.skip("otp not available on DescopeClientAsync") - - auth_mgmt_key = "test-auth-mgmt-key" - - # Test 1: Direct auth_management_key setting (without refresh token) - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - - client.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "x-descope-project-id": PROJECT_ID, - "Authorization": f"Bearer {PROJECT_ID}:{auth_mgmt_key}", - }, - json={ - "loginId": "test@example.com", - "user": {"email": "test@example.com"}, - "email": "test@example.com", - }, - params=None, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test 2: Environment variable auth_management_key setting - env_auth_mgmt_key = "env-auth-mgmt-key" - with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_env = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - - client_env.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "x-descope-project-id": PROJECT_ID, - "Authorization": f"Bearer {PROJECT_ID}:{env_auth_mgmt_key}", - }, - json={ - "loginId": "test@example.com", - "user": {"email": "test@example.com"}, - "email": "test@example.com", - }, - follow_redirects=False, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test 3: Direct parameter takes priority over environment variable - direct_auth_mgmt_key = "direct-auth-mgmt-key" - with patch.dict("os.environ", {"DESCOPE_AUTH_MANAGEMENT_KEY": env_auth_mgmt_key}): - client_priority = client_factory.make( - PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=direct_auth_mgmt_key - ) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - - client_priority.otp.sign_up(DeliveryMethod.EMAIL, "test@example.com") - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "x-descope-project-id": PROJECT_ID, - "Authorization": f"Bearer {PROJECT_ID}:{direct_auth_mgmt_key}", - }, - json={ - "loginId": "test@example.com", - "user": {"email": "test@example.com"}, - "email": "test@example.com", - }, - params=None, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - async def test_auth_management_key_with_refresh_token(self, client_factory): - if client_factory.mode != "sync": - pytest.skip("otp not available on DescopeClientAsync") - - auth_mgmt_key = "test-auth-mgmt-key" - client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT, auth_management_key=auth_mgmt_key) - - # Test with refresh token function - refresh_token = "test_refresh_token" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "n***@example.com"} - mock_post.return_value = my_mock_response - - client.otp.update_user_email("old@example.com", "new@example.com", refresh_token) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}:{auth_mgmt_key}", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "old@example.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Without auth_management_key — refresh token only in Authorization - client_no_auth = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "n***@example.com"} - mock_post.return_value = my_mock_response - - client_no_auth.otp.update_user_email("old@example.com", "new@example.com", refresh_token) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - json={ - "loginId": "old@example.com", - "email": "new@example.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # ------------------------------------------------------------------ - # base_url parameter - # ------------------------------------------------------------------ - - async def test_base_url_setting(self, client_factory): - custom_base_url = "https://api.use1.descope.com" - client = client_factory.make(PROJECT_ID, base_url=custom_base_url, public_key=PUBLIC_KEY_DICT) - - # Auth HTTP client base_url is available on both sync and async - assert client._auth.http_client.base_url == custom_base_url - - # Management HTTP client is sync-only - if client_factory.mode == "sync": - assert client._mgmt._http.base_url == custom_base_url - - async def test_base_url_none(self, client_factory): - client = client_factory.make(PROJECT_ID, base_url=None, public_key=PUBLIC_KEY_DICT) - - expected_base_url = common.DEFAULT_BASE_URL - assert client._auth.http_client.base_url == expected_base_url - - if client_factory.mode == "sync": - assert client._mgmt._http.base_url == expected_base_url - - # ------------------------------------------------------------------ - # Verbose mode - # ------------------------------------------------------------------ - - async def test_verbose_mode_disabled_by_default(self, client_factory): - client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT) - assert client.get_last_response() is None - - async def test_verbose_mode_enabled(self, client_factory): - client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT, verbose=True) - # Just verify it doesn't error when enabled - assert client.get_last_response() is None # No requests made yet - - async def test_verbose_mode_captures_mgmt_response(self, client_factory): - if client_factory.mode != "sync": - pytest.skip("mgmt not available on DescopeClientAsync") - - mock_response = mock.Mock() - mock_response.is_success = True - mock_response.json.return_value = {"user": {"id": "u1", "loginIds": ["test@example.com"]}} - mock_response.headers = {"cf-ray": "mgmt-ray-123", "x-request-id": "req-456"} - mock_response.status_code = 200 - - with patch("httpx.post", return_value=mock_response): - client = client_factory.make( - PROJECT_ID, - public_key=PUBLIC_KEY_DICT, - management_key="test-mgmt-key", - verbose=True, - ) - client.mgmt.user.create(login_id="test@example.com") - - last_resp = client.get_last_response() - assert last_resp is not None - assert last_resp["user"]["id"] == "u1" - assert last_resp.headers.get("cf-ray") == "mgmt-ray-123" - assert last_resp.status_code == 200 diff --git a/tests/test_totp_parity.py b/tests/test_totp_parity.py deleted file mode 100644 index 686fd9d16..000000000 --- a/tests/test_totp_parity.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Parity port of test_totp.py using the unified sync/async fixture infrastructure. - -Structure mirrors the original: one class, one test method per operation. -Each method runs twice — once for sync DescopeClient and once for DescopeClientAsync — -via pytest's parametrised ``client_factory`` fixture from conftest. - -Payload assertions (assert_http_called) are included where the original had them: - - test_sign_in: refresh-token call body + headers - - test_update_user: call body + headers - - test_sign_up: asserts result is not None (original had no payload assertion) -""" - -from __future__ import annotations - -import pytest - -from descope import AuthException -from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, - REFRESH_SESSION_COOKIE_NAME, - EndpointsV1, - LoginOptions, -) -from tests.conftest import PROJECT_ID, PUBLIC_KEY_DICT, make_response -from tests.testutils import SSLMatcher - -from . import common - -# --------------------------------------------------------------------------- -# Module-level constants -# --------------------------------------------------------------------------- - -# drn=DSR, exp=2264443061 (far future) — signed with PUBLIC_KEY_DICT (P2Cu kid) -VALID_REFRESH_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0NDMwNjEsImlhdCI6MTY1OTY0MzA2MSwiaXNzIjoiUDJDdUM5eXYy" - "VUd0R0kxbzg0Z0NaRWI5cUVRVyIsInN1YiI6IlUyQ3VDUHVKZ1BXSEdCNVA0R21mYnVQR2hHVm0ifQ" - ".mRo9FihYMR3qnQT06Mj3CJ5X0uTCEcXASZqfLLUv0cPCLBtBqYTbuK-ZRDnV4e4N6zGCNX2a3jjpbyqbViOx" - "ICCNSxJsVb-sdsSujtEXwVMsTTLnpWmNsMbOUiKmoME0" -) - -# drn=DS, exp=2493061415 (far future) — signed with PUBLIC_KEY_DICT (P2Cu kid) -VALID_SESSION_TOKEN = ( - "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3VDOXl2MlVHdEdJMW84NGdDWkViOXFFUVciLCJ0eXAiOiJKV1QifQ" - ".eyJkcm4iOiJEUyIsImV4cCI6MjQ5MzA2MTQxNSwiaWF0IjoxNjU5NjQzMDYxLCJpc3MiOiJQMkN1Qzl5djJVR3" - "RHSTFvODRnQ1pFYjlxRVFXIiwic3ViIjoiVTJDdUNQdUpnUFdIR0I1UDRHbWZidVBHaEdWbSJ9" - ".gMalOv1GhqYVsfITcOc7Jv_fibX1Iof6AFy2KCVmyHmU2KwATT6XYXsHjBFFLq262Pg-LS1IX9f_DV3ppzvb1p" - "SY4ccsP6WDGd1vJpjp3wFBP9Sji6WXL0SCCJUFIyJR" -) - - -# --------------------------------------------------------------------------- -# Helper: mode-aware HTTP call assertion (same as test_descope_client_parity.py) -# --------------------------------------------------------------------------- - - -def assert_http_called(mock_http, mode, url, **kwargs): - """Assert the patched HTTP mock was called with the given arguments. - - In sync mode, ``verify`` and ``timeout`` are passed per-call; in async mode - they are set on the ``httpx.AsyncClient`` constructor and absent from each call. - This helper injects them automatically for sync so test bodies stay identical. - """ - if mode == "sync": - kwargs.setdefault("verify", SSLMatcher()) - kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS) - mock_http.assert_called_with(url, **kwargs) - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestTOTP: - # ------------------------------------------------------------------ - # sign_up - # ------------------------------------------------------------------ - - async def test_sign_up(self, client_factory): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up("", signup_user_details)) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up(None, signup_user_details)) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) - - # Success - data = {"provisioningURL": "http://dummy.com", "image": "imagedata", "key": "k01"} - with client.mock_post(make_response(data)): - result = await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) - assert result is not None - - # ------------------------------------------------------------------ - # sign_in_code - # ------------------------------------------------------------------ - - async def test_sign_in(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - refresh_token = "dummy refresh token" - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code(None, "1234")) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("", "1234")) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", None)) - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) - - # Success + MFA-without-refresh check - success_resp = make_response( - {"sessionJwt": VALID_SESSION_TOKEN}, - cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, - ) - with client.mock_post(success_resp): - result = await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "1234")) - assert result is not None - # MFA stepup requires a refresh token — omitting it must raise - with pytest.raises(AuthException): - await client.invoke(client.totp.sign_in_code("dummy@dummy.com", "code", LoginOptions(mfa=True))) - - # Verify refresh token propagates correctly into the request - with client.mock_post(success_resp) as mock_post: - await client.invoke( - client.totp.sign_in_code( - "dummy@dummy.com", - "1234", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - ) - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_totp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "code": "1234", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - ) - - # ------------------------------------------------------------------ - # update_user - # ------------------------------------------------------------------ - - async def test_update_user(self, client_factory): - client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - valid_refresh_token = VALID_REFRESH_TOKEN - valid_response = { - "provisioningURL": "http://dummy.com", - "image": "imagedata", - "key": "k01", - "error": "", - } - - # Validation errors — no HTTP call made - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user(None, "")) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("", "")) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", None)) - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", "")) - - # HTTP error - with client.mock_post(make_response(status=500)): - with pytest.raises(AuthException): - await client.invoke(client.totp.update_user("dummy@dummy.com", "dummy refresh token")) - - # Success + payload assertion - with client.mock_post(make_response(valid_response)) as mock_post: - res = await client.invoke(client.totp.update_user("dummy@dummy.com", valid_refresh_token)) - assert res == valid_response - assert_http_called( - mock_post, - client.mode, - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_totp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {PROJECT_ID}:{valid_refresh_token}", - "x-descope-project-id": PROJECT_ID, - }, - params=None, - json={"loginId": "dummy@dummy.com"}, - follow_redirects=False, - ) From e050b20db69d2306d90b30fd246cb1126cb243e1 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:35:52 +0300 Subject: [PATCH 11/17] refactor: remove decorative dash-banner section headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~45 three-line dash-banner comment blocks across 8 files with nothing — class names, method names, and docstrings already describe what those banners labelled. The only banner-style block kept is the SOCKS-proxy workaround doc in conftest.py, which is real documentation. Co-Authored-By: Claude Sonnet 4.6 --- descope/_client_base.py | 8 ---- descope/authmethod/_totp_base.py | 8 ---- descope/descope_client_async.py | 12 ----- tests/conftest.py | 29 ------------ tests/test_descope_client.py | 81 -------------------------------- tests/test_http_client_async.py | 40 ---------------- tests/test_totp.py | 21 --------- tests/testutils.py | 5 +- 8 files changed, 1 insertion(+), 203 deletions(-) diff --git a/descope/_client_base.py b/descope/_client_base.py index cf0f87a06..6e4c9bd33 100644 --- a/descope/_client_base.py +++ b/descope/_client_base.py @@ -71,10 +71,6 @@ def __init__( ) self._auth = Auth(project_id, public_key, jwt_validation_leeway, http_client=_auth_http) - # ------------------------------------------------------------------------- - # Argument-validation guards — reused by both DescopeClient and DescopeClientAsync - # ------------------------------------------------------------------------- - @staticmethod def _ensure_present(value, message: str, error_type: str = ERROR_TYPE_INVALID_ARGUMENT) -> None: """Raise AuthException(400, error_type, message) if *value* is falsy.""" @@ -131,10 +127,6 @@ def _fetch_rate_limit_tier(self, mgmt_http) -> None: except Exception as e: logger.warning("License handshake failed: %s", e) - # ------------------------------------------------------------------------- - # Pure sync helpers — no I/O - # ------------------------------------------------------------------------- - def validate_session(self, session_token: str, audience: Iterable[str] | str | None = None) -> dict: """ Validate a session token. Pure CPU — no network I/O. diff --git a/descope/authmethod/_totp_base.py b/descope/authmethod/_totp_base.py index 19433879e..d206312e0 100644 --- a/descope/authmethod/_totp_base.py +++ b/descope/authmethod/_totp_base.py @@ -17,10 +17,6 @@ class TOTPBase: - ``AsyncTOTP(TOTPBase, AsyncAuthBase)`` — async, uses ``self._http`` (``AsyncHTTPClient``) """ - # ------------------------------------------------------------------------- - # Argument-validation guards - # ------------------------------------------------------------------------- - @staticmethod def _validate_login_id(login_id: str) -> None: if not login_id: @@ -36,10 +32,6 @@ def _validate_refresh_token(refresh_token: str) -> None: if not refresh_token: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") - # ------------------------------------------------------------------------- - # Request body composers - # ------------------------------------------------------------------------- - @staticmethod def _compose_signup_body(login_id: str, user: Optional[dict]) -> dict: body: dict[str, str | dict] = {"loginId": login_id} diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 4187d90a3..306c0cee7 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -92,10 +92,6 @@ def __init__( def totp(self) -> TOTPAsync: return self._totp - # ------------------------------------------------------------------------- - # Lifecycle - # ------------------------------------------------------------------------- - async def aclose(self) -> None: """Close the underlying async HTTP clients and release connections.""" await self._auth_http.aclose() @@ -107,10 +103,6 @@ async def __aenter__(self) -> DescopeClientAsync: async def __aexit__(self, *args) -> None: await self.aclose() - # ------------------------------------------------------------------------- - # Async session methods — network I/O - # ------------------------------------------------------------------------- - async def refresh_session(self, refresh_token: str, audience: Iterable[str] | str | None = None) -> dict: """Refresh a session using the refresh token. Makes an async network call.""" self._ensure_present(refresh_token, "Refresh token is required to refresh a session", ERROR_TYPE_INVALID_TOKEN) @@ -209,10 +201,6 @@ async def select_tenant(self, tenant_id: str, refresh_token: str) -> dict: response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None ) - # ------------------------------------------------------------------------- - # Debugging - # ------------------------------------------------------------------------- - def get_last_response(self): """Get the last HTTP response when verbose mode is enabled.""" mgmt_resp = self._mgmt_http.get_last_response() diff --git a/tests/conftest.py b/tests/conftest.py index 7ab2750c8..fe592d73b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,18 +34,10 @@ # yield # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Shared test constants -# --------------------------------------------------------------------------- PROJECT_ID = "dummy" -# --------------------------------------------------------------------------- -# Response factory -# --------------------------------------------------------------------------- - - def assert_http_called(mock_http, mode, url, **kwargs): """Assert the patched HTTP mock was called with the given arguments. @@ -73,11 +65,6 @@ def make_response(json_data=None, *, status=200, cookies=None): return m -# --------------------------------------------------------------------------- -# UnifiedClient — mode-agnostic wrapper for sync / async clients -# --------------------------------------------------------------------------- - - class UnifiedClient: """ Wraps DescopeClient or DescopeClientAsync with a uniform interface so test @@ -94,16 +81,12 @@ def __init__(self, mode: str, raw): def __getattr__(self, name): return getattr(self._raw, name) - # --- Execution --- - async def invoke(self, maybe_coro): """Uniformly run a sync return value or an async coroutine.""" if asyncio.iscoroutine(maybe_coro): return await maybe_coro return maybe_coro - # --- Mock helpers --- - @contextmanager def mock_get(self, response): with self._patch_ctx("get", response) as m: @@ -114,8 +97,6 @@ def mock_post(self, response): with self._patch_ctx("post", response) as m: yield m - # --- Internals --- - def _patch_ctx(self, method: str, response): """ Patch the right layer per mode: @@ -132,11 +113,6 @@ def _patch_ctx(self, method: str, response): ) -# --------------------------------------------------------------------------- -# ClientFactory — for tests that need custom construction arguments -# --------------------------------------------------------------------------- - - class ClientFactory: """ Use via the ``client_factory`` fixture when a test must control construction @@ -160,11 +136,6 @@ def make(self, *args, **kwargs) -> UnifiedClient: return UnifiedClient("async", client) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - @pytest.fixture(params=["sync", "async"]) async def descope_client(request): """ diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index c642a8f3d..18cab0eb2 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -33,10 +33,6 @@ from . import common -# --------------------------------------------------------------------------- -# Module-level constants -# --------------------------------------------------------------------------- - PUBLIC_KEY_STR = json.dumps(PUBLIC_KEY_DICT) # The original setUp public_key_dict (kid=2Bt5…) used by a handful of tests @@ -70,16 +66,7 @@ _INVALID_PAYLOAD_TOKEN = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEUyIsImNvb2tpZVBhdGgiOiIvIiwiZXhwIjoxNjU3Nzk2Njc4LCJpYXQiOjE2NTc3OTYwNzgsImlzcyI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInN1YiI6IjJCdEVIa2dPdTAybG1NeHpQSWV4ZE10VXcxTSJ9.lTUKMIjkrdsfryREYrgz4jMV7M0-JF-Q-KNlI0xZhamYqnSYtvzdwAoYiyWamx22XrN5SZkcmVZ5bsx-g2C0p5VMbnmmxEaxcnsFJHqVAJUYEv5HGQHumN50DYSlLXXg" -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - class TestDescopeClient: - # ------------------------------------------------------------------ - # Construction validation - # ------------------------------------------------------------------ - async def test_descope_client(self, client_factory): with pytest.raises(AuthException): client_factory.make(None, "dummy") @@ -102,10 +89,6 @@ async def test_project_id_from_env_without_env(self, client_factory): with pytest.raises(AuthException): client_factory.make("") - # ------------------------------------------------------------------ - # Management client (sync-only) - # ------------------------------------------------------------------ - async def test_mgmt(self, descope_client): if descope_client.mode != "sync": pytest.skip("mgmt not available on DescopeClientAsync") @@ -148,10 +131,6 @@ async def test_mgmt(self, descope_client): except AuthException: pytest.fail("failed to initiate outbound_application_by_token without management key") - # ------------------------------------------------------------------ - # logout / logout_all - # ------------------------------------------------------------------ - async def test_logout(self, descope_client): with pytest.raises(AuthException): await descope_client.invoke(descope_client.logout(None)) @@ -174,10 +153,6 @@ async def test_logout_all(self, descope_client): with descope_client.mock_post(make_response(status=200)): assert await descope_client.invoke(descope_client.logout_all("")) is not None - # ------------------------------------------------------------------ - # me - # ------------------------------------------------------------------ - async def test_me(self, descope_client): with pytest.raises(AuthException): await descope_client.invoke(descope_client.me(None)) @@ -204,10 +179,6 @@ async def test_me(self, descope_client): params=None, ) - # ------------------------------------------------------------------ - # my_tenants - # ------------------------------------------------------------------ - async def test_my_tenants(self, descope_client): with pytest.raises(AuthException): await descope_client.invoke(descope_client.my_tenants(None)) @@ -241,10 +212,6 @@ async def test_my_tenants(self, descope_client): params=None, ) - # ------------------------------------------------------------------ - # history - # ------------------------------------------------------------------ - async def test_history(self, descope_client): with pytest.raises(AuthException): await descope_client.invoke(descope_client.history(None)) @@ -290,10 +257,6 @@ async def test_history(self, descope_client): params=None, ) - # ------------------------------------------------------------------ - # validate_session — pure-CPU helper (no IO) - # ------------------------------------------------------------------ - async def test_validate_session(self, client_factory): # Client with the 2Bt5 key (matching the kid in _INVALID_PAYLOAD_TOKEN) client = client_factory.make(PROJECT_ID, DUMMY_PUBLIC_KEY_DICT) @@ -391,10 +354,6 @@ async def test_validate_session_valid_tokens(self, client_factory): client3.validate_and_refresh_session(expired_jwt_token, valid_refresh_for_expire_test) ) - # ------------------------------------------------------------------ - # Exception object shapes (no client needed) - # ------------------------------------------------------------------ - def test_exception_object(self): ex = AuthException(401, "dummy-type", "dummy error message") assert str(ex) is not None @@ -419,10 +378,6 @@ def test_api_rate_limit_exception_object(self): assert ex.error_message == "API rate limit exceeded" assert ex.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, "") == "9" - # ------------------------------------------------------------------ - # Expired token + refresh flows - # ------------------------------------------------------------------ - async def test_expired_token(self, client_factory): # expired DS token (kid=P2Cu, exp=1657798328 — past) expired_jwt_token = ( @@ -491,10 +446,6 @@ async def test_expired_token(self, client_factory): dummy_client.validate_and_refresh_session(expired_jwt_token2, valid_refresh_token2) ) - # ------------------------------------------------------------------ - # Public key loading errors - # ------------------------------------------------------------------ - async def test_public_key_load(self, client_factory): # Test key without kty property invalid_public_key = deepcopy(PUBLIC_KEY_DICT) @@ -517,10 +468,6 @@ async def test_public_key_load(self, client_factory): client_factory.make(PROJECT_ID, invalid_public_key) assert exc_info.value.status_code == 500 - # ------------------------------------------------------------------ - # Client property surface - # ------------------------------------------------------------------ - async def test_client_properties(self, descope_client): # totp is available on both sync and async clients assert descope_client.totp is not None, "Empty totp object" @@ -535,10 +482,6 @@ async def test_client_properties(self, descope_client): assert descope_client.sso is not None, "Empty saml object" assert descope_client.webauthn is not None, "Empty webauthN object" - # ------------------------------------------------------------------ - # Permission / role helpers — pure-CPU - # ------------------------------------------------------------------ - async def test_validate_permissions(self, descope_client): jwt_response = {} assert descope_client.validate_permissions(jwt_response, ["Perm 1"]) is False @@ -653,10 +596,6 @@ async def test_get_matched_roles(self, descope_client): "Role 2", ] - # ------------------------------------------------------------------ - # exchange_access_key - # ------------------------------------------------------------------ - async def test_exchange_access_key_empty_param(self, descope_client): with pytest.raises(AuthException) as exc_info: await descope_client.invoke(descope_client.exchange_access_key("")) @@ -688,10 +627,6 @@ async def test_exchange_access_key(self, descope_client): follow_redirects=False, ) - # ------------------------------------------------------------------ - # JWT validation leeway - # ------------------------------------------------------------------ - async def test_jwt_validation_leeway(self, client_factory): # Negative leeway forces even far-future tokens to appear expired min_int = -sys.maxsize - 1 @@ -703,10 +638,6 @@ async def test_jwt_validation_leeway(self, client_factory): assert exc_info.value.error_message is not None assert "nbf in future" in exc_info.value.error_message - # ------------------------------------------------------------------ - # select_tenant - # ------------------------------------------------------------------ - async def test_select_tenant(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) @@ -730,10 +661,6 @@ async def test_select_tenant(self, client_factory): follow_redirects=False, ) - # ------------------------------------------------------------------ - # auth_management_key header propagation (sync-only: uses otp) - # ------------------------------------------------------------------ - async def test_auth_management_key_with_functions(self, client_factory): if client_factory.mode != "sync": pytest.skip("otp not available on DescopeClientAsync") @@ -898,10 +825,6 @@ async def test_auth_management_key_with_refresh_token(self, client_factory): timeout=DEFAULT_TIMEOUT_SECONDS, ) - # ------------------------------------------------------------------ - # base_url parameter - # ------------------------------------------------------------------ - async def test_base_url_setting(self, client_factory): custom_base_url = "https://api.use1.descope.com" client = client_factory.make(PROJECT_ID, base_url=custom_base_url, public_key=PUBLIC_KEY_DICT) @@ -922,10 +845,6 @@ async def test_base_url_none(self, client_factory): if client_factory.mode == "sync": assert client._mgmt._http.base_url == expected_base_url - # ------------------------------------------------------------------ - # Verbose mode - # ------------------------------------------------------------------ - async def test_verbose_mode_disabled_by_default(self, client_factory): client = client_factory.make(PROJECT_ID, public_key=PUBLIC_KEY_DICT) assert client.get_last_response() is None diff --git a/tests/test_http_client_async.py b/tests/test_http_client_async.py index 2c7a720ae..25f100dcb 100644 --- a/tests/test_http_client_async.py +++ b/tests/test_http_client_async.py @@ -9,11 +9,6 @@ from descope.http_client_async import HTTPClientAsync from tests.testutils import SSLMatcher -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - _DEFAULT_BASE_URL = "https://api.descope.com" @@ -45,11 +40,6 @@ def make_resp(*, status=200, json_data=None, headers=None, text=""): return r -# --------------------------------------------------------------------------- -# 1. Init — AsyncClient is constructed with right verify/timeout -# --------------------------------------------------------------------------- - - class TestAsyncHTTPClientInit: def test_secure_passes_ssl_context(self): with patch("descope.http_client_async.httpx.AsyncClient") as mock_cls: @@ -71,11 +61,6 @@ def test_empty_project_id_raises(self): assert exc_info.value.status_code == 400 -# --------------------------------------------------------------------------- -# 2. Verbs — each verb forwards the right URL, headers, body, params -# --------------------------------------------------------------------------- - - class TestAsyncHTTPClientVerbs: async def test_get(self): client = make_async_client(project_id="test123") @@ -154,11 +139,6 @@ async def test_delete(self): assert call.kwargs["follow_redirects"] is False -# --------------------------------------------------------------------------- -# 3. Retry — mirrors TestRetryMechanism from test_http_client.py -# --------------------------------------------------------------------------- - - class TestAsyncRetry: async def test_retries_on_retryable_codes(self): for status_code in _RETRY_STATUS_CODES: @@ -291,11 +271,6 @@ async def test_retry_works_for_delete(self): assert client._async_client.delete.await_count == 2 -# --------------------------------------------------------------------------- -# 4. Verbose mode -# --------------------------------------------------------------------------- - - class TestAsyncVerbose: async def test_get_captures_response_when_verbose(self): client = make_async_client(verbose=True) @@ -355,11 +330,6 @@ async def test_delete_captures_response_when_verbose(self): assert last.status_code == 200 -# --------------------------------------------------------------------------- -# 5. Error raising — inherited _raise_from_response fires after await -# --------------------------------------------------------------------------- - - class TestAsyncErrors: async def test_raises_auth_exception_on_500(self): client = make_async_client() @@ -395,11 +365,6 @@ async def test_raises_rate_limit_when_json_fails(self): await client.get("/x") -# --------------------------------------------------------------------------- -# 6. Lifecycle — aclose and context manager -# --------------------------------------------------------------------------- - - class TestAsyncLifecycle: async def test_aclose_delegates_to_async_client(self): client = make_async_client() @@ -418,11 +383,6 @@ async def test_context_manager_yields_client_and_closes(self): c._async_client.aclose.assert_awaited_once() -# --------------------------------------------------------------------------- -# 7. Headers — management key propagation -# --------------------------------------------------------------------------- - - class TestAsyncHTTPClientHeaders: async def test_management_key_in_authorization_header(self): """auth_management_key is baked into the Authorization header on every verb call.""" diff --git a/tests/test_totp.py b/tests/test_totp.py index e2ea1c55a..b8a3833a3 100644 --- a/tests/test_totp.py +++ b/tests/test_totp.py @@ -11,21 +11,8 @@ from . import common -# --------------------------------------------------------------------------- -# Module-level constants -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - class TestTOTP: - # ------------------------------------------------------------------ - # sign_up - # ------------------------------------------------------------------ - async def test_sign_up(self, client_factory): signup_user_details = { "username": "jhon", @@ -52,10 +39,6 @@ async def test_sign_up(self, client_factory): result = await client.invoke(client.totp.sign_up("dummy@dummy.com", signup_user_details)) assert result is not None - # ------------------------------------------------------------------ - # sign_in_code - # ------------------------------------------------------------------ - async def test_sign_in(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) refresh_token = "dummy refresh token" @@ -119,10 +102,6 @@ async def test_sign_in(self, client_factory): follow_redirects=False, ) - # ------------------------------------------------------------------ - # update_user - # ------------------------------------------------------------------ - async def test_update_user(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) valid_refresh_token = VALID_REFRESH_TOKEN diff --git a/tests/testutils.py b/tests/testutils.py index 7fe251023..d68bd6d85 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -1,9 +1,6 @@ from ssl import SSLContext -# --------------------------------------------------------------------------- -# Canonical test key + JWTs — all signed with PUBLIC_KEY_DICT (kid=P2Cu…) -# --------------------------------------------------------------------------- - +# Test fixtures: ES384 key + JWTs signed with kid=P2CuC9yv2UGtGI1o84gCZEb9qEQW PUBLIC_KEY_DICT = { "alg": "ES384", "crv": "P-384", From 4ab5438829dd14ad1826fc2d35f16041d22b816d Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:27:43 +0300 Subject: [PATCH 12/17] feat: add async support for all 8 remaining auth methods For each method (OAuth, SSO, SAML, OTP, MagicLink, EnchantedLink, Password, WebAuthn): extract an I/O-free __base.py with shared static helpers, refactor the sync class to inherit it, add a parallel async class using AsyncAuthBase, and rewrite the test file to the parametrized client_factory harness so every test runs under both sync and async. Wires all new async classes into DescopeClientAsync. --- descope/authmethod/_enchantedlink_base.py | 96 +++ descope/authmethod/_magiclink_base.py | 121 +++ descope/authmethod/_oauth_base.py | 30 + descope/authmethod/_otp_base.py | 119 +++ descope/authmethod/_password_base.py | 20 + descope/authmethod/_saml_base.py | 36 + descope/authmethod/_sso_base.py | 48 ++ descope/authmethod/_webauthn_base.py | 49 ++ descope/authmethod/enchantedlink.py | 126 +-- descope/authmethod/enchantedlink_async.py | 112 +++ descope/authmethod/magiclink.py | 105 +-- descope/authmethod/magiclink_async.py | 146 ++++ descope/authmethod/oauth.py | 33 +- descope/authmethod/oauth_async.py | 53 ++ descope/authmethod/otp.py | 103 +-- descope/authmethod/otp_async.py | 156 ++++ descope/authmethod/password.py | 10 +- descope/authmethod/password_async.py | 120 +++ descope/authmethod/saml.py | 36 +- descope/authmethod/saml_async.py | 50 ++ descope/authmethod/sso.py | 49 +- descope/authmethod/sso_async.py | 59 ++ descope/authmethod/webauthn.py | 73 +- descope/authmethod/webauthn_async.py | 132 +++ descope/descope_client_async.py | 49 ++ tests/test_enchantedlink.py | 734 +++++----------- tests/test_magiclink.py | 921 +++++--------------- tests/test_oauth.py | 275 +++--- tests/test_otp.py | 995 +++++----------------- tests/test_password.py | 759 ++++++----------- tests/test_saml.py | 266 +++--- tests/test_sso.py | 407 +++------ tests/test_webauthn.py | 781 +++++++---------- 33 files changed, 3014 insertions(+), 4055 deletions(-) create mode 100644 descope/authmethod/_enchantedlink_base.py create mode 100644 descope/authmethod/_magiclink_base.py create mode 100644 descope/authmethod/_oauth_base.py create mode 100644 descope/authmethod/_otp_base.py create mode 100644 descope/authmethod/_password_base.py create mode 100644 descope/authmethod/_saml_base.py create mode 100644 descope/authmethod/_sso_base.py create mode 100644 descope/authmethod/_webauthn_base.py create mode 100644 descope/authmethod/enchantedlink_async.py create mode 100644 descope/authmethod/magiclink_async.py create mode 100644 descope/authmethod/oauth_async.py create mode 100644 descope/authmethod/otp_async.py create mode 100644 descope/authmethod/password_async.py create mode 100644 descope/authmethod/saml_async.py create mode 100644 descope/authmethod/sso_async.py create mode 100644 descope/authmethod/webauthn_async.py diff --git a/descope/authmethod/_enchantedlink_base.py b/descope/authmethod/_enchantedlink_base.py new file mode 100644 index 000000000..2bf481dcd --- /dev/null +++ b/descope/authmethod/_enchantedlink_base.py @@ -0,0 +1,96 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from descope.auth import Auth +from descope.common import ( + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + signup_options_to_dict, +) + + +class EnchantedLinkBase: + """Shared, I/O-free base for EnchantedLink auth-method classes. + + Holds only static URL composers and body builders — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``EnchantedLink(EnchantedLinkBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthBase)`` — async, uses ``self._http`` + """ + + @staticmethod + def _compose_signin_url() -> str: + return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) + + @staticmethod + def _compose_signup_url() -> str: + return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, DeliveryMethod.EMAIL) + + @staticmethod + def _compose_sign_up_or_in_url() -> str: + return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) + + @staticmethod + def _compose_signin_body( + login_id: str, + uri: str, + login_options: LoginOptions | None = None, + ) -> dict: + return { + "loginId": login_id, + "URI": uri, + "loginOptions": login_options.__dict__ if login_options else {}, + } + + @staticmethod + def _compose_signup_body( + login_id: str, + uri: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} + + if signup_options is not None: + body["loginOptions"] = signup_options_to_dict(signup_options) + + if user is not None: + body["user"] = user + method_str, val = Auth.get_login_id_by_method(DeliveryMethod.EMAIL, user) + body[method_str] = val + return body + + @staticmethod + def _compose_verify_body(token: str) -> dict: + return {"token": token} + + @staticmethod + def _compose_update_user_email_body( + login_id: str, + email: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "email": email, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body + + @staticmethod + def _compose_get_session_body(pending_ref: str) -> dict: + return {"pendingRef": pending_ref} diff --git a/descope/authmethod/_magiclink_base.py b/descope/authmethod/_magiclink_base.py new file mode 100644 index 000000000..db8166f06 --- /dev/null +++ b/descope/authmethod/_magiclink_base.py @@ -0,0 +1,121 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from descope.auth import Auth +from descope.common import ( + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + signup_options_to_dict, +) + + +class MagicLinkBase: + """Shared, I/O-free base for MagicLink auth-method classes. + + Holds only static URL composers and body builders — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``MagicLink(MagicLinkBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``MagicLinkAsync(MagicLinkBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _compose_signin_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_in_auth_magiclink_path, method) + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_auth_magiclink_path, method) + + @staticmethod + def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_magiclink_path, method) + + @staticmethod + def _compose_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.update_user_phone_magiclink_path, method) + + @staticmethod + def _compose_signin_body( + login_id: str, + uri: str, + login_options: LoginOptions | None = None, + ) -> dict: + return { + "loginId": login_id, + "URI": uri, + "loginOptions": login_options.__dict__ if login_options else {}, + } + + @staticmethod + def _compose_signup_body( + method: DeliveryMethod, + login_id: str, + uri: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} + + if signup_options is not None: + body["loginOptions"] = signup_options_to_dict(signup_options) + + if user is not None: + body["user"] = user + method_str, val = Auth.get_login_id_by_method(method, user) + body[method_str] = val + return body + + @staticmethod + def _compose_verify_body(token: str) -> dict: + return {"token": token} + + @staticmethod + def _compose_update_user_email_body( + login_id: str, + email: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "email": email, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body + + @staticmethod + def _compose_update_user_phone_body( + login_id: str, + phone: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "phone": phone, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body diff --git a/descope/authmethod/_oauth_base.py b/descope/authmethod/_oauth_base.py new file mode 100644 index 000000000..de73c0eb7 --- /dev/null +++ b/descope/authmethod/_oauth_base.py @@ -0,0 +1,30 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + + +class OAuthBase: + """Shared, I/O-free base for OAuth auth-method classes. + + Holds only static validation guards and body/params composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``OAuth(OAuthBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``OAuthAsync(OAuthBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _verify_provider(oauth_provider: str) -> bool: + if oauth_provider == "" or oauth_provider is None: + return False + return True + + @staticmethod + def _compose_start_params(provider: str, return_url: str = "") -> dict: + res: dict = {"provider": provider} + if return_url: + res["redirectURL"] = return_url + return res + + @staticmethod + def _compose_exchange_body(code: str) -> dict: + return {"code": code} diff --git a/descope/authmethod/_otp_base.py b/descope/authmethod/_otp_base.py new file mode 100644 index 000000000..74c1b7943 --- /dev/null +++ b/descope/authmethod/_otp_base.py @@ -0,0 +1,119 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from descope.auth import Auth +from descope.common import ( + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + signup_options_to_dict, +) + + +class OTPBase: + """Shared, I/O-free base for OTP auth-method classes. + + Holds only static URL composers and body builders — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``OTP(OTPBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``OTPAsync(OTPBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _compose_signup_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_auth_otp_path, method) + + @staticmethod + def _compose_signin_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_in_auth_otp_path, method) + + @staticmethod + def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_otp_path, method) + + @staticmethod + def _compose_verify_code_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.verify_code_auth_path, method) + + @staticmethod + def _compose_update_phone_url(method: DeliveryMethod) -> str: + return Auth.compose_url(EndpointsV1.update_user_phone_otp_path, method) + + @staticmethod + def _compose_signup_body( + method: DeliveryMethod, + login_id: str, + user: dict, + signup_options: SignUpOptions | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = {"loginId": login_id} + + if signup_options is not None: + body["loginOptions"] = signup_options_to_dict(signup_options) + + if user is not None: + body["user"] = user + method_str, val = Auth.get_login_id_by_method(method, user) + body[method_str] = val + return body + + @staticmethod + def _compose_signin_body(login_id: str, login_options: LoginOptions | None = None) -> dict: + return { + "loginId": login_id, + "loginOptions": login_options.__dict__ if login_options else {}, + } + + @staticmethod + def _compose_verify_code_body(login_id: str, code: str) -> dict: + return {"loginId": login_id, "code": code} + + @staticmethod + def _compose_update_user_email_body( + login_id: str, + email: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "email": email, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body + + @staticmethod + def _compose_update_user_phone_body( + login_id: str, + phone: str, + add_to_login_ids: bool, + on_merge_use_existing: bool, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + body: dict[str, str | bool | dict] = { + "loginId": login_id, + "phone": phone, + "addToLoginIDs": add_to_login_ids, + "onMergeUseExisting": on_merge_use_existing, + } + if template_options is not None: + body["templateOptions"] = template_options + if template_id is not None: + body["templateId"] = template_id + if provider_id is not None: + body["providerId"] = provider_id + return body diff --git a/descope/authmethod/_password_base.py b/descope/authmethod/_password_base.py new file mode 100644 index 000000000..1ed479d88 --- /dev/null +++ b/descope/authmethod/_password_base.py @@ -0,0 +1,20 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + + +class PasswordBase: + """Shared, I/O-free base for Password auth-method classes. + + Holds only static body composers — no network I/O, no ``__init__``. + The two concrete subclasses add the network layer: + + - ``Password(PasswordBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``PasswordAsync(PasswordBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _compose_signup_body(login_id: str, password: str, user: dict | None) -> dict: + body: dict[str, str | bool | dict] = {"loginId": login_id, "password": password} + if user is not None: + body["user"] = user + return body diff --git a/descope/authmethod/_saml_base.py b/descope/authmethod/_saml_base.py new file mode 100644 index 000000000..a2d34dc96 --- /dev/null +++ b/descope/authmethod/_saml_base.py @@ -0,0 +1,36 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class SAMLBase: + """Shared, I/O-free base for SAML auth-method classes (deprecated — use SSO). + + Holds only static validation guards and body/params composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``SAML(SAMLBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``SAMLAsync(SAMLBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _validate_tenant(tenant: str) -> None: + if not tenant: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") + + @staticmethod + def _validate_return_url(return_url: str) -> None: + if not return_url: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Return url cannot be empty") + + @staticmethod + def _compose_start_params(tenant: str, return_url: str) -> dict: + res: dict = {"tenant": tenant} + if return_url is not None and return_url != "": + res["redirectURL"] = return_url + return res + + @staticmethod + def _compose_exchange_body(code: str) -> dict: + return {"code": code} diff --git a/descope/authmethod/_sso_base.py b/descope/authmethod/_sso_base.py new file mode 100644 index 000000000..e1bde6544 --- /dev/null +++ b/descope/authmethod/_sso_base.py @@ -0,0 +1,48 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import Any, Dict, Optional + +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class SSOBase: + """Shared, I/O-free base for SSO auth-method classes. + + Holds only static validation guards and body/params composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``SSO(SSOBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``SSOAsync(SSOBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _validate_tenant(tenant: str) -> None: + if not tenant: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") + + @staticmethod + def _compose_start_params( + tenant: str, + return_url: str, + prompt: str, + sso_id: str, + login_hint: str, + force_authn: Optional[bool], + ) -> dict: + res: Dict[str, Any] = {"tenant": tenant} + if return_url is not None and return_url != "": + res["redirectURL"] = return_url + if prompt is not None and prompt != "": + res["prompt"] = prompt + if sso_id is not None and sso_id != "": + res["ssoId"] = sso_id + if login_hint is not None and login_hint != "": + res["loginHint"] = login_hint + if force_authn is not None: + res["forceAuthn"] = force_authn + return res + + @staticmethod + def _compose_exchange_body(code: str) -> dict: + return {"code": code} diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py new file mode 100644 index 000000000..bef9cbcf6 --- /dev/null +++ b/descope/authmethod/_webauthn_base.py @@ -0,0 +1,49 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import Optional + +from descope.common import LoginOptions + + +class WebAuthnBase: + """Shared, I/O-free base for WebAuthn auth-method classes. + + Holds only static body composers — no network I/O, no ``__init__``. + The two concrete subclasses add the network layer: + + - ``WebAuthn(WebAuthnBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``WebAuthnAsync(WebAuthnBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _compose_sign_up_start_body(login_id: str, user: dict, origin: str) -> dict: + user.update({"loginId": login_id}) + return {"user": user, "origin": origin} + + @staticmethod + def _compose_sign_in_start_body(login_id: str, origin: str, login_options: Optional[LoginOptions] = None) -> dict: + return { + "loginId": login_id, + "origin": origin, + "loginOptions": login_options.__dict__ if login_options else {}, + } + + @staticmethod + def _compose_sign_up_or_in_start_body(login_id: str, origin: str) -> dict: + return {"loginId": login_id, "origin": origin} + + @staticmethod + def _compose_sign_up_in_finish_body(transaction_id: str, response) -> dict: + return {"transactionId": transaction_id, "response": response} + + @staticmethod + def _compose_update_start_body(login_id: str, origin: str) -> dict: + body: dict = {"loginId": login_id} + if origin: + body["origin"] = origin + return body + + @staticmethod + def _compose_update_finish_body(transaction_id: str, response: str) -> dict: + return {"transactionId": transaction_id, "response": response} diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index f98d32e26..0128d444b 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -1,22 +1,20 @@ from __future__ import annotations -import httpx - from descope._auth_base import AuthBase from descope.auth import Auth +from descope.authmethod._enchantedlink_base import EnchantedLinkBase from descope.common import ( REFRESH_SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1, LoginOptions, SignUpOptions, - signup_options_to_dict, validate_refresh_token_provided, ) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class EnchantedLink(AuthBase): +class EnchantedLink(EnchantedLinkBase, AuthBase): def sign_in( self, login_id: str, @@ -25,18 +23,14 @@ def sign_in( refresh_token: str | None = None, ) -> dict: if not login_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "login_id is empty", - ) + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") validate_refresh_token_provided(login_options, refresh_token) - body = EnchantedLink._compose_signin_body(login_id, uri, login_options) - url = EnchantedLink._compose_signin_url() + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_signin_url() response = self._http.post(url, body=body, pswd=refresh_token) - return EnchantedLink._get_pending_ref_from_response(response) + return response.json() def sign_up( self, @@ -55,10 +49,10 @@ def sign_up( f"Login ID {login_id} is not valid for email", ) - body = EnchantedLink._compose_signup_body(login_id, uri, user, signup_options) - url = EnchantedLink._compose_signup_url() + body = self._compose_signup_body(login_id, uri, user, signup_options) + url = self._compose_signup_url() response = self._http.post(url, body=body) - return EnchantedLink._get_pending_ref_from_response(response) + return response.json() def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: login_options: LoginOptions | None = None @@ -69,28 +63,21 @@ def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | template_id=signup_options.templateId, ) - body = EnchantedLink._compose_signin_body( - login_id, - uri, - login_options, - ) - url = EnchantedLink._compose_sign_up_or_in_url() + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_sign_up_or_in_url() response = self._http.post(url, body=body) - return EnchantedLink._get_pending_ref_from_response(response) + return response.json() def get_session(self, pending_ref: str) -> dict: uri = EndpointsV1.get_session_enchantedlink_auth_path - body = EnchantedLink._compose_get_session_body(pending_ref) + body = self._compose_get_session_body(pending_ref) response = self._http.post(uri, body=body) resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None - ) - return jwt_response + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None) - def verify(self, token: str): + def verify(self, token: str) -> None: uri = EndpointsV1.verify_enchantedlink_auth_path - body = EnchantedLink._compose_verify_body(token) + body = self._compose_verify_body(token) self._http.post(uri, body=body) def update_user_email( @@ -109,7 +96,7 @@ def update_user_email( Auth.validate_email(email) - body = EnchantedLink._compose_update_user_email_body( + body = self._compose_update_user_email_body( login_id, email, add_to_login_ids, @@ -120,83 +107,4 @@ def update_user_email( ) uri = EndpointsV1.update_user_email_enchantedlink_path response = self._http.post(uri, body=body, pswd=refresh_token) - return EnchantedLink._get_pending_ref_from_response(response) - - @staticmethod - def _compose_signin_url() -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) - - @staticmethod - def _compose_signup_url() -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_enchantedlink_path, DeliveryMethod.EMAIL) - - @staticmethod - def _compose_sign_up_or_in_url() -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) - - @staticmethod - def _compose_signin_body( - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - ) -> dict: - return { - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_signup_body( - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(DeliveryMethod.EMAIL, user) - body[method_str] = val - return body - - @staticmethod - def _compose_verify_body(token: str) -> dict: - return {"token": token} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - - return body - - @staticmethod - def _compose_get_session_body(pending_ref: str) -> dict: - return {"pendingRef": pending_ref} - - @staticmethod - def _get_pending_ref_from_response(response: httpx.Response) -> dict: return response.json() diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py new file mode 100644 index 000000000..353f241b8 --- /dev/null +++ b/descope/authmethod/enchantedlink_async.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from descope._auth_base import AsyncAuthBase +from descope.auth import Auth +from descope.authmethod._enchantedlink_base import EnchantedLinkBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthBase): + """Async EnchantedLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_in( + self, + login_id: str, + uri: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") + + validate_refresh_token_provided(login_options, refresh_token) + + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_signin_url() + response = await self._http.post(url, body=body, pswd=refresh_token) + return response.json() + + async def sign_up( + self, + login_id: str, + uri: str, + user: dict | None, + signup_options: SignUpOptions | None = None, + ) -> dict: + if not user: + user = {} + + if not self._auth.adjust_and_verify_delivery_method(DeliveryMethod.EMAIL, login_id, user): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Login ID {login_id} is not valid for email", + ) + + body = self._compose_signup_body(login_id, uri, user, signup_options) + url = self._compose_signup_url() + response = await self._http.post(url, body=body) + return response.json() + + async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: + login_options: LoginOptions | None = None + if signup_options is not None: + login_options = LoginOptions( + custom_claims=signup_options.customClaims, + template_options=signup_options.templateOptions, + template_id=signup_options.templateId, + ) + + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_sign_up_or_in_url() + response = await self._http.post(url, body=body) + return response.json() + + async def get_session(self, pending_ref: str) -> dict: + uri = EndpointsV1.get_session_enchantedlink_auth_path + body = self._compose_get_session_body(pending_ref) + response = await self._http.post(uri, body=body) + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None) + + async def verify(self, token: str) -> None: + uri = EndpointsV1.verify_enchantedlink_auth_path + body = self._compose_verify_body(token) + await self._http.post(uri, body=body) + + async def update_user_email( + self, + login_id: str, + email: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + Auth.validate_email(email) + + body = self._compose_update_user_email_body( + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + uri = EndpointsV1.update_user_email_enchantedlink_path + response = await self._http.post(uri, body=body, pswd=refresh_token) + return response.json() diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py index 2e9d1406e..92962c281 100644 --- a/descope/authmethod/magiclink.py +++ b/descope/authmethod/magiclink.py @@ -4,19 +4,19 @@ from descope._auth_base import AuthBase from descope.auth import Auth +from descope.authmethod._magiclink_base import MagicLinkBase from descope.common import ( REFRESH_SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1, LoginOptions, SignUpOptions, - signup_options_to_dict, validate_refresh_token_provided, ) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class MagicLink(AuthBase): +class MagicLink(MagicLinkBase, AuthBase): def sign_in( self, method: DeliveryMethod, @@ -153,104 +153,3 @@ def update_user_phone( url = EndpointsV1.update_user_phone_magiclink_path response = self._http.post(url, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) - - @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_magiclink_path, method) - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_magiclink_path, method) - - @staticmethod - def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_magiclink_path, method) - - @staticmethod - def _compose_update_phone_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.update_user_phone_magiclink_path, method) - - @staticmethod - def _compose_signin_body( - login_id: str, - uri: str, - login_options: LoginOptions | None = None, - ) -> dict: - return { - "loginId": login_id, - "URI": uri, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_signup_body( - method: DeliveryMethod, - login_id: str, - uri: str, - user: dict | None = None, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "URI": uri} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(method, user) - body[method_str] = val - return body - - @staticmethod - def _compose_verify_body(token: str) -> dict: - return {"token": token} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - - return body - - @staticmethod - def _compose_update_user_phone_body( - login_id: str, - phone: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "phone": phone, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - - return body diff --git a/descope/authmethod/magiclink_async.py b/descope/authmethod/magiclink_async.py new file mode 100644 index 000000000..a21430a98 --- /dev/null +++ b/descope/authmethod/magiclink_async.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from typing import Iterable + +from descope._auth_base import AsyncAuthBase +from descope.auth import Auth +from descope.authmethod._magiclink_base import MagicLinkBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class MagicLinkAsync(MagicLinkBase, AsyncAuthBase): + """Async MagicLink auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_in( + self, + method: DeliveryMethod, + login_id: str, + uri: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier is empty") + + validate_refresh_token_provided(login_options, refresh_token) + + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_signin_url(method) + response = await self._http.post(url, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), method) + + async def sign_up( + self, + method: DeliveryMethod, + login_id: str, + uri: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> str: + if not user: + user = {} + + if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Login ID {login_id} is not valid by delivery method {method}", + ) + + body = self._compose_signup_body(method, login_id, uri, user, signup_options) + url = self._compose_signup_url(method) + response = await self._http.post(url, body=body) + return Auth.extract_masked_address(response.json(), method) + + async def sign_up_or_in( + self, + method: DeliveryMethod, + login_id: str, + uri: str, + signup_options: SignUpOptions | None = None, + ) -> str: + login_options: LoginOptions | None = None + if signup_options is not None: + login_options = LoginOptions( + custom_claims=signup_options.customClaims, + template_options=signup_options.templateOptions, + template_id=signup_options.templateId, + ) + body = self._compose_signin_body(login_id, uri, login_options) + url = self._compose_sign_up_or_in_url(method) + response = await self._http.post(url, body=body) + return Auth.extract_masked_address(response.json(), method) + + async def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: + url = EndpointsV1.verify_magiclink_auth_path + body = self._compose_verify_body(token) + response = await self._http.post(url, body=body) + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def update_user_email( + self, + login_id: str, + email: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + Auth.validate_email(email) + + body = self._compose_update_user_email_body( + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + url = EndpointsV1.update_user_email_magiclink_path + response = await self._http.post(url, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) + + async def update_user_phone( + self, + method: DeliveryMethod, + login_id: str, + phone: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + Auth.validate_phone(method, phone) + + body = self._compose_update_user_phone_body( + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + url = EndpointsV1.update_user_phone_magiclink_path + response = await self._http.post(url, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), DeliveryMethod.SMS) diff --git a/descope/authmethod/oauth.py b/descope/authmethod/oauth.py index c9f0a83dc..d77ffc633 100644 --- a/descope/authmethod/oauth.py +++ b/descope/authmethod/oauth.py @@ -1,11 +1,19 @@ +from __future__ import annotations + from typing import Optional from descope._auth_base import AuthBase -from descope.common import EndpointsV1, LoginOptions, validate_refresh_token_provided +from descope.authmethod._oauth_base import OAuthBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class OAuth(AuthBase): +class OAuth(OAuthBase, AuthBase): def start( self, provider: str, @@ -35,18 +43,11 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") uri = EndpointsV1.oauth_exchange_token_path - return self._auth.exchange_token(uri, code) - - @staticmethod - def _verify_provider(oauth_provider: str) -> bool: - if oauth_provider == "" or oauth_provider is None: - return False - return True - - @staticmethod - def _compose_start_params(provider: str, return_url: str) -> dict: - res = {"provider": provider} - if return_url: - res["redirectURL"] = return_url - return res + body = self._compose_exchange_body(code) + response = self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/oauth_async.py b/descope/authmethod/oauth_async.py new file mode 100644 index 000000000..5891b3091 --- /dev/null +++ b/descope/authmethod/oauth_async.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import Optional + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._oauth_base import OAuthBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class OAuthAsync(OAuthBase, AsyncAuthBase): + """Async OAuth auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def start( + self, + provider: str, + return_url: str = "", + login_options: Optional[LoginOptions] = None, + refresh_token: Optional[str] = None, + ) -> dict: + if not self._verify_provider(provider): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Unknown OAuth provider: {provider}", + ) + + validate_refresh_token_provided(login_options, refresh_token) + + uri = EndpointsV1.oauth_start_path + params = self._compose_start_params(provider, return_url) + response = await self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, + ) + return response.json() + + async def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + uri = EndpointsV1.oauth_exchange_token_path + body = self._compose_exchange_body(code) + response = await self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index 25a0e0af3..a5ab20d05 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -4,19 +4,19 @@ from descope._auth_base import AuthBase from descope.auth import Auth +from descope.authmethod._otp_base import OTPBase from descope.common import ( REFRESH_SESSION_COOKIE_NAME, DeliveryMethod, EndpointsV1, LoginOptions, SignUpOptions, - signup_options_to_dict, validate_refresh_token_provided, ) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class OTP(AuthBase): +class OTP(OTPBase, AuthBase): def sign_in( self, method: DeliveryMethod, @@ -246,102 +246,3 @@ def update_user_phone( ) response = self._http.post(uri, body=body, pswd=refresh_token) return Auth.extract_masked_address(response.json(), method) - - @staticmethod - def _compose_signup_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_auth_otp_path, method) - - @staticmethod - def _compose_signin_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_in_auth_otp_path, method) - - @staticmethod - def _compose_sign_up_or_in_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.sign_up_or_in_auth_otp_path, method) - - @staticmethod - def _compose_verify_code_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.verify_code_auth_path, method) - - @staticmethod - def _compose_update_phone_url(method: DeliveryMethod) -> str: - return Auth.compose_url(EndpointsV1.update_user_phone_otp_path, method) - - @staticmethod - def _compose_signup_body( - method: DeliveryMethod, - login_id: str, - user: dict, - signup_options: SignUpOptions | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id} - - if signup_options is not None: - body["loginOptions"] = signup_options_to_dict(signup_options) - - if user is not None: - body["user"] = user - method_str, val = Auth.get_login_id_by_method(method, user) - body[method_str] = val - return body - - @staticmethod - def _compose_signin_body(login_id: str, login_options: LoginOptions | None = None) -> dict: - return { - "loginId": login_id, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_verify_code_body(login_id: str, code: str) -> dict: - return {"loginId": login_id, "code": code} - - @staticmethod - def _compose_update_user_email_body( - login_id: str, - email: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "email": email, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - - return body - - @staticmethod - def _compose_update_user_phone_body( - login_id: str, - phone: str, - add_to_login_ids: bool, - on_merge_use_existing: bool, - template_options: dict | None = None, - template_id: str | None = None, - provider_id: str | None = None, - ) -> dict: - body: dict[str, str | bool | dict] = { - "loginId": login_id, - "phone": phone, - "addToLoginIDs": add_to_login_ids, - "onMergeUseExisting": on_merge_use_existing, - } - if template_options is not None: - body["templateOptions"] = template_options - if template_id is not None: - body["templateId"] = template_id - if provider_id is not None: - body["providerId"] = provider_id - - return body diff --git a/descope/authmethod/otp_async.py b/descope/authmethod/otp_async.py new file mode 100644 index 000000000..8943ca951 --- /dev/null +++ b/descope/authmethod/otp_async.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from typing import Iterable + +from descope._auth_base import AsyncAuthBase +from descope.auth import Auth +from descope.authmethod._otp_base import OTPBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + DeliveryMethod, + EndpointsV1, + LoginOptions, + SignUpOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class OTPAsync(OTPBase, AsyncAuthBase): + """Async OTP auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_in( + self, + method: DeliveryMethod, + login_id: str, + login_options: LoginOptions | None = None, + refresh_token: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + validate_refresh_token_provided(login_options, refresh_token) + + uri = self._compose_signin_url(method) + body = self._compose_signin_body(login_id, login_options) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), method) + + async def sign_up( + self, + method: DeliveryMethod, + login_id: str, + user: dict | None = None, + signup_options: SignUpOptions | None = None, + ) -> str: + if not user: + user = {} + + if not self._auth.adjust_and_verify_delivery_method(method, login_id, user): + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Login ID {login_id} is not valid by delivery method {method}", + ) + + uri = self._compose_signup_url(method) + body = self._compose_signup_body(method, login_id, user, signup_options) + response = await self._http.post(uri, body=body) + return Auth.extract_masked_address(response.json(), method) + + async def sign_up_or_in( + self, + method: DeliveryMethod, + login_id: str, + signup_options: SignUpOptions | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + uri = self._compose_sign_up_or_in_url(method) + login_options: LoginOptions | None = None + if signup_options is not None: + login_options = LoginOptions( + custom_claims=signup_options.customClaims, + template_options=signup_options.templateOptions, + template_id=signup_options.templateId, + ) + body = self._compose_signin_body(login_id, login_options) + response = await self._http.post(uri, body=body) + return Auth.extract_masked_address(response.json(), method) + + async def verify_code( + self, + method: DeliveryMethod, + login_id: str, + code: str, + audience: str | None | Iterable[str] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + uri = self._compose_verify_code_url(method) + body = self._compose_verify_code_body(login_id, code) + response = await self._http.post(uri, body=body) + + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def update_user_email( + self, + login_id: str, + email: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + Auth.validate_email(email) + + uri = EndpointsV1.update_user_email_otp_path + body = self._compose_update_user_email_body( + login_id, + email, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL) + + async def update_user_phone( + self, + method: DeliveryMethod, + login_id: str, + phone: str, + refresh_token: str, + add_to_login_ids: bool = False, + on_merge_use_existing: bool = False, + template_options: dict | None = None, + template_id: str | None = None, + provider_id: str | None = None, + ) -> str: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + Auth.validate_phone(method, phone) + + uri = self._compose_update_phone_url(method) + body = self._compose_update_user_phone_body( + login_id, + phone, + add_to_login_ids, + on_merge_use_existing, + template_options, + template_id, + provider_id, + ) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return Auth.extract_masked_address(response.json(), method) diff --git a/descope/authmethod/password.py b/descope/authmethod/password.py index 8f06923bf..851a88b96 100644 --- a/descope/authmethod/password.py +++ b/descope/authmethod/password.py @@ -3,11 +3,12 @@ from typing import Iterable from descope._auth_base import AuthBase +from descope.authmethod._password_base import PasswordBase from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class Password(AuthBase): +class Password(PasswordBase, AuthBase): def sign_up( self, login_id: str, @@ -229,10 +230,3 @@ def get_policy(self) -> dict: response = self._http.get(uri=EndpointsV1.password_policy_path) return response.json() - - @staticmethod - def _compose_signup_body(login_id: str, password: str, user: dict | None) -> dict: - body: dict[str, str | bool | dict] = {"loginId": login_id, "password": password} - if user is not None: - body["user"] = user - return body diff --git a/descope/authmethod/password_async.py b/descope/authmethod/password_async.py new file mode 100644 index 000000000..096426b32 --- /dev/null +++ b/descope/authmethod/password_async.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Iterable + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._password_base import PasswordBase +from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class PasswordAsync(PasswordBase, AsyncAuthBase): + """Async Password auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_up( + self, + login_id: str, + password: str, + user: dict | None = None, + audience: str | None | Iterable[str] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if not password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty") + + uri = EndpointsV1.sign_up_password_path + body = self._compose_signup_body(login_id, password, user) + response = await self._http.post(uri, body=body) + + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def sign_in( + self, + login_id: str, + password: str, + audience: str | None | Iterable[str] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if not password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty") + + uri = EndpointsV1.sign_in_password_path + response = await self._http.post(uri, body={"loginId": login_id, "password": password}) + + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def send_reset( + self, + login_id: str, + redirect_url: str | None = None, + template_options: dict | None = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + uri = EndpointsV1.send_reset_password_path + body: dict[str, str | bool | dict | None] = { + "loginId": login_id, + "redirectUrl": redirect_url, + } + if template_options is not None: + body["templateOptions"] = template_options + + response = await self._http.post(uri, body=body) + return response.json() + + async def update(self, login_id: str, new_password: str, refresh_token: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if not new_password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") + + if not refresh_token: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + + uri = EndpointsV1.update_password_path + await self._http.post( + uri, + body={"loginId": login_id, "newPassword": new_password}, + pswd=refresh_token, + ) + + async def replace( + self, + login_id: str, + old_password: str, + new_password: str, + audience: str | None | Iterable[str] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if not old_password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty") + + if not new_password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") + + uri = EndpointsV1.replace_password_path + response = await self._http.post( + uri, + body={ + "loginId": login_id, + "oldPassword": old_password, + "newPassword": new_password, + }, + ) + + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def get_policy(self) -> dict: + response = await self._http.get(uri=EndpointsV1.password_policy_path) + return response.json() diff --git a/descope/authmethod/saml.py b/descope/authmethod/saml.py index f0b5dc421..0e78e6c87 100644 --- a/descope/authmethod/saml.py +++ b/descope/authmethod/saml.py @@ -1,12 +1,20 @@ +from __future__ import annotations + from typing import Optional from descope._auth_base import AuthBase -from descope.common import EndpointsV1, LoginOptions, validate_refresh_token_provided +from descope.authmethod._saml_base import SAMLBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # This class is DEPRECATED please use SSO instead -class SAML(AuthBase): +class SAML(SAMLBase, AuthBase): def start( self, tenant: str, @@ -17,16 +25,13 @@ def start( """ DEPRECATED """ - if not tenant: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") - - if not return_url: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Return url cannot be empty") + self._validate_tenant(tenant) + self._validate_return_url(return_url) validate_refresh_token_provided(login_options, refresh_token) uri = EndpointsV1.auth_saml_start_path - params = SAML._compose_start_params(tenant, return_url) + params = self._compose_start_params(tenant, return_url) response = self._http.post( uri, body=login_options.__dict__ if login_options else {}, @@ -37,12 +42,11 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") uri = EndpointsV1.saml_exchange_token_path - return self._auth.exchange_token(uri, code) - - @staticmethod - def _compose_start_params(tenant: str, return_url: str) -> dict: - res = {"tenant": tenant} - if return_url is not None and return_url != "": - res["redirectURL"] = return_url - return res + body = self._compose_exchange_body(code) + response = self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/saml_async.py b/descope/authmethod/saml_async.py new file mode 100644 index 000000000..009e88baa --- /dev/null +++ b/descope/authmethod/saml_async.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Optional + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._saml_base import SAMLBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +# This class is DEPRECATED please use SSOAsync instead +class SAMLAsync(SAMLBase, AsyncAuthBase): + """Async SAML auth-method (deprecated — use SSOAsync). All network calls are coroutines.""" + + async def start( + self, + tenant: str, + return_url: Optional[str] = None, + login_options: Optional[LoginOptions] = None, + refresh_token: Optional[str] = None, + ) -> dict: + self._validate_tenant(tenant) + self._validate_return_url(return_url) + + validate_refresh_token_provided(login_options, refresh_token) + + uri = EndpointsV1.auth_saml_start_path + params = self._compose_start_params(tenant, return_url) + response = await self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, + ) + return response.json() + + async def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + uri = EndpointsV1.saml_exchange_token_path + body = self._compose_exchange_body(code) + response = await self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/sso.py b/descope/authmethod/sso.py index fa637d1d3..2d90e4ab6 100644 --- a/descope/authmethod/sso.py +++ b/descope/authmethod/sso.py @@ -1,11 +1,19 @@ -from typing import Any, Dict, Optional +from __future__ import annotations + +from typing import Optional from descope._auth_base import AuthBase -from descope.common import EndpointsV1, LoginOptions, validate_refresh_token_provided +from descope.authmethod._sso_base import SSOBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class SSO(AuthBase): +class SSO(SSOBase, AuthBase): def start( self, tenant: str, @@ -34,13 +42,12 @@ def start( Return dict in the format {'url': 'http://dummy.com/login..'} """ - if not tenant: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") + self._validate_tenant(tenant) validate_refresh_token_provided(login_options, refresh_token) uri = EndpointsV1.auth_sso_start_path - params = SSO._compose_start_params( + params = self._compose_start_params( tenant, return_url if return_url else "", prompt if prompt else "", @@ -58,27 +65,11 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") uri = EndpointsV1.sso_exchange_token_path - return self._auth.exchange_token(uri, code) - - @staticmethod - def _compose_start_params( - tenant: str, - return_url: str, - prompt: str, - sso_id: str, - login_hint: str, - force_authn: Optional[bool], - ) -> dict: - res: Dict[str, Any] = {"tenant": tenant} - if return_url is not None and return_url != "": - res["redirectURL"] = return_url - if prompt is not None and prompt != "": - res["prompt"] = prompt - if sso_id is not None and sso_id != "": - res["ssoId"] = sso_id - if login_hint is not None and login_hint != "": - res["loginHint"] = login_hint - if force_authn is not None: - res["forceAuthn"] = force_authn - return res + body = self._compose_exchange_body(code) + response = self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/sso_async.py b/descope/authmethod/sso_async.py new file mode 100644 index 000000000..52b8334a8 --- /dev/null +++ b/descope/authmethod/sso_async.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Optional + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._sso_base import SSOBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class SSOAsync(SSOBase, AsyncAuthBase): + """Async SSO auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def start( + self, + tenant: str, + return_url: Optional[str] = None, + login_options: Optional[LoginOptions] = None, + refresh_token: Optional[str] = None, + prompt: Optional[str] = None, + sso_id: Optional[str] = None, + login_hint: Optional[str] = None, + force_authn: Optional[bool] = None, + ) -> dict: + self._validate_tenant(tenant) + + validate_refresh_token_provided(login_options, refresh_token) + + uri = EndpointsV1.auth_sso_start_path + params = self._compose_start_params( + tenant, + return_url if return_url else "", + prompt if prompt else "", + sso_id if sso_id else "", + login_hint if login_hint else "", + force_authn, + ) + response = await self._http.post( + uri, + body=login_options.__dict__ if login_options else {}, + params=params, + pswd=refresh_token, + ) + return response.json() + + async def exchange_token(self, code: str) -> dict: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + uri = EndpointsV1.sso_exchange_token_path + body = self._compose_exchange_body(code) + response = await self._http.post(uri, body=body) + return self._auth.generate_jwt_response( + response.json(), response.cookies.get(REFRESH_SESSION_COOKIE_NAME), None + ) diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index cc6a12993..f0415e83a 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -1,8 +1,9 @@ -from typing import Iterable, Optional, Union +from __future__ import annotations -from httpx import Response +from typing import Iterable, Optional, Union from descope._auth_base import AuthBase +from descope.authmethod._webauthn_base import WebAuthnBase from descope.common import ( REFRESH_SESSION_COOKIE_NAME, EndpointsV1, @@ -12,7 +13,7 @@ from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -class WebAuthn(AuthBase): +class WebAuthn(WebAuthnBase, AuthBase): def sign_up_start( self, login_id: Optional[str], @@ -32,14 +33,14 @@ def sign_up_start( user = {} uri = EndpointsV1.sign_up_auth_webauthn_start_path - body = WebAuthn._compose_sign_up_start_body(login_id, user, origin) + body = self._compose_sign_up_start_body(login_id, user, origin) response = self._http.post(uri, body=body) return response.json() def sign_up_finish( self, transaction_id: str, - response: Response, + response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: """ @@ -51,13 +52,10 @@ def sign_up_finish( if not response: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") uri = EndpointsV1.sign_up_auth_webauthn_finish_path - body = WebAuthn._compose_sign_up_in_finish_body(transaction_id, response) + body = self._compose_sign_up_in_finish_body(transaction_id, response) response = self._http.post(uri, body=body) resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) def sign_in_start( self, @@ -78,14 +76,14 @@ def sign_in_start( validate_refresh_token_provided(login_options, refresh_token) uri = EndpointsV1.sign_in_auth_webauthn_start_path - body = WebAuthn._compose_sign_in_start_body(login_id, origin, login_options) + body = self._compose_sign_in_start_body(login_id, origin, login_options) response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() def sign_in_finish( self, transaction_id: str, - response: Response, + response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: """ @@ -98,13 +96,10 @@ def sign_in_finish( raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") uri = EndpointsV1.sign_in_auth_webauthn_finish_path - body = WebAuthn._compose_sign_up_in_finish_body(transaction_id, response) + body = self._compose_sign_up_in_finish_body(transaction_id, response) response = self._http.post(uri, body=body) resp = response.json() - jwt_response = self._auth.generate_jwt_response( - resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience - ) - return jwt_response + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) def sign_up_or_in_start( self, @@ -121,11 +116,11 @@ def sign_up_or_in_start( raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path - body = WebAuthn._compose_sign_up_or_in_start_body(login_id, origin) + body = self._compose_sign_up_or_in_start_body(login_id, origin) response = self._http.post(uri, body=body) return response.json() - def update_start(self, login_id: str, refresh_token: str, origin: str): + def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: """ Docs """ @@ -136,7 +131,7 @@ def update_start(self, login_id: str, refresh_token: str, origin: str): raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") uri = EndpointsV1.update_auth_webauthn_start_path - body = WebAuthn._compose_update_start_body(login_id, origin) + body = self._compose_update_start_body(login_id, origin) response = self._http.post(uri, body=body, pswd=refresh_token) return response.json() @@ -151,41 +146,5 @@ def update_finish(self, transaction_id: str, response: str) -> None: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") uri = EndpointsV1.update_auth_webauthn_finish_path - body = WebAuthn._compose_update_finish_body(transaction_id, response) + body = self._compose_update_finish_body(transaction_id, response) self._http.post(uri, body=body) - - @staticmethod - def _compose_sign_up_start_body(login_id: str, user: dict, origin: str) -> dict: - user.update({"loginId": login_id}) - body = {"user": user, "origin": origin} - return body - - @staticmethod - def _compose_sign_in_start_body(login_id: str, origin: str, login_options: Optional[LoginOptions] = None) -> dict: - return { - "loginId": login_id, - "origin": origin, - "loginOptions": login_options.__dict__ if login_options else {}, - } - - @staticmethod - def _compose_sign_up_or_in_start_body(login_id: str, origin: str) -> dict: - return { - "loginId": login_id, - "origin": origin, - } - - @staticmethod - def _compose_sign_up_in_finish_body(transaction_id: str, response: Response) -> dict: - return {"transactionId": transaction_id, "response": response} - - @staticmethod - def _compose_update_start_body(login_id: str, origin: str) -> dict: - body = {"loginId": login_id} - if origin: - body["origin"] = origin - return body - - @staticmethod - def _compose_update_finish_body(transaction_id: str, response: str) -> dict: - return {"transactionId": transaction_id, "response": response} diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py new file mode 100644 index 000000000..7ac4afd66 --- /dev/null +++ b/descope/authmethod/webauthn_async.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from typing import Iterable, Optional, Union + +from descope._auth_base import AsyncAuthBase +from descope.authmethod._webauthn_base import WebAuthnBase +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, + validate_refresh_token_provided, +) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + + +class WebAuthnAsync(WebAuthnBase, AsyncAuthBase): + """Async WebAuthn auth-method. All network calls are coroutines; validation is sync (no I/O).""" + + async def sign_up_start( + self, + login_id: Optional[str], + origin: Optional[str], + user: Optional[dict] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + if not origin: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + + if not user: + user = {} + + uri = EndpointsV1.sign_up_auth_webauthn_start_path + body = self._compose_sign_up_start_body(login_id, user, origin) + response = await self._http.post(uri, body=body) + return response.json() + + async def sign_up_finish( + self, + transaction_id: str, + response, + audience: Union[str, None, Iterable[str]] = None, + ) -> dict: + if not transaction_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") + + if not response: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + + uri = EndpointsV1.sign_up_auth_webauthn_finish_path + body = self._compose_sign_up_in_finish_body(transaction_id, response) + response = await self._http.post(uri, body=body) + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def sign_in_start( + self, + login_id: str, + origin: str, + login_options: Optional[LoginOptions] = None, + refresh_token: Optional[str] = None, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + if not origin: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + + validate_refresh_token_provided(login_options, refresh_token) + + uri = EndpointsV1.sign_in_auth_webauthn_start_path + body = self._compose_sign_in_start_body(login_id, origin, login_options) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return response.json() + + async def sign_in_finish( + self, + transaction_id: str, + response, + audience: Union[str, None, Iterable[str]] = None, + ) -> dict: + if not transaction_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") + + if not response: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + + uri = EndpointsV1.sign_in_auth_webauthn_finish_path + body = self._compose_sign_up_in_finish_body(transaction_id, response) + response = await self._http.post(uri, body=body) + resp = response.json() + return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + + async def sign_up_or_in_start( + self, + login_id: str, + origin: str, + ) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + if not origin: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + + uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path + body = self._compose_sign_up_or_in_start_body(login_id, origin) + response = await self._http.post(uri, body=body) + return response.json() + + async def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + if not refresh_token: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + + uri = EndpointsV1.update_auth_webauthn_start_path + body = self._compose_update_start_body(login_id, origin) + response = await self._http.post(uri, body=body, pswd=refresh_token) + return response.json() + + async def update_finish(self, transaction_id: str, response: str) -> None: + if not transaction_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") + + if not response: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + + uri = EndpointsV1.update_auth_webauthn_finish_path + body = self._compose_update_finish_body(transaction_id, response) + await self._http.post(uri, body=body) diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 306c0cee7..11443df31 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -4,7 +4,15 @@ from typing import Iterable from descope._client_base import DescopeClientBase +from descope.authmethod.enchantedlink_async import EnchantedLinkAsync +from descope.authmethod.magiclink_async import MagicLinkAsync +from descope.authmethod.oauth_async import OAuthAsync +from descope.authmethod.otp_async import OTPAsync +from descope.authmethod.password_async import PasswordAsync +from descope.authmethod.saml_async import SAMLAsync +from descope.authmethod.sso_async import SSOAsync from descope.authmethod.totp_async import TOTPAsync +from descope.authmethod.webauthn_async import WebAuthnAsync from descope.common import ( DEFAULT_TIMEOUT_SECONDS, REFRESH_SESSION_COOKIE_NAME, @@ -83,15 +91,56 @@ def __init__( ) self._fga_cache_url = fga_cache_url + self._magiclink = MagicLinkAsync(self._auth, self._auth_http) + self._enchantedlink = EnchantedLinkAsync(self._auth, self._auth_http) + self._oauth = OAuthAsync(self._auth, self._auth_http) + self._saml = SAMLAsync(self._auth, self._auth_http) # deprecated + self._sso = SSOAsync(self._auth, self._auth_http) + self._otp = OTPAsync(self._auth, self._auth_http) self._totp = TOTPAsync(self._auth, self._auth_http) + self._webauthn = WebAuthnAsync(self._auth, self._auth_http) + self._password = PasswordAsync(self._auth, self._auth_http) if self._mgmt_http.management_key: self._fetch_rate_limit_tier(self._mgmt_http) + @property + def magiclink(self) -> MagicLinkAsync: + return self._magiclink + + @property + def enchantedlink(self) -> EnchantedLinkAsync: + return self._enchantedlink + + @property + def otp(self) -> OTPAsync: + return self._otp + @property def totp(self) -> TOTPAsync: return self._totp + @property + def oauth(self) -> OAuthAsync: + return self._oauth + + # deprecated (use sso instead) + @property + def saml(self) -> SAMLAsync: + return self._saml + + @property + def sso(self) -> SSOAsync: + return self._sso + + @property + def webauthn(self) -> WebAuthnAsync: + return self._webauthn + + @property + def password(self) -> PasswordAsync: + return self._password + async def aclose(self) -> None: """Close the underlying async HTTP clients and release connections.""" await self._auth_http.aclose() diff --git a/tests/test_enchantedlink.py b/tests/test_enchantedlink.py index eefcc9787..c9b5eb47a 100644 --- a/tests/test_enchantedlink.py +++ b/tests/test_enchantedlink.py @@ -1,567 +1,231 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest -from descope import SESSION_COOKIE_NAME, AuthException -from descope.auth import Auth -from descope.authmethod.enchantedlink import EnchantedLink # noqa: F401 +from descope import AuthException +from descope.authmethod.enchantedlink import EnchantedLink from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, - SignUpOptions, ) -from tests.testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestEnchantedLink(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - +class TestEnchantedLink: def test_compose_urls(self): - self.assertEqual( - EnchantedLink._compose_signin_url(), - "/v1/auth/enchantedlink/signin/email", - ) + assert EnchantedLink._compose_signin_url() == "/v1/auth/enchantedlink/signin/email" + assert EnchantedLink._compose_signup_url() == "/v1/auth/enchantedlink/signup/email" + assert EnchantedLink._compose_sign_up_or_in_url() == "/v1/auth/enchantedlink/signup-in/email" def test_compose_body(self): - self.assertEqual( - EnchantedLink._compose_signin_body("id1", "uri1"), - { - "loginId": "id1", - "URI": "uri1", - "loginOptions": {}, + assert EnchantedLink._compose_signin_body("id1", "uri1") == { + "loginId": "id1", + "URI": "uri1", + "loginOptions": {}, + } + assert EnchantedLink._compose_verify_body("t1") == {"token": "t1"} + assert EnchantedLink._compose_get_session_body("ref1") == {"pendingRef": "ref1"} + + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_in("", "http://r.me")) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_in(None, "http://r.me")) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_in("id", "http://r.me", LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me")) + + # Success + payload + with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})) as mock_post: + result = await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, }, + params=None, + json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, ) + async def test_sign_in_with_login_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - self.assertEqual( - EnchantedLink._compose_signin_body("id1", "uri1", lo), - { - "loginId": "id1", - "URI": "uri1", - "loginOptions": { - "stepup": True, - "mfa": False, - "customClaims": {"k1": "v1"}, - }, - }, - ) - self.assertEqual( - EnchantedLink._compose_signup_body("id1", "uri1", {"email": "email1"}), - { - "loginId": "id1", - "URI": "uri1", - "user": {"email": "email1"}, - "email": "email1", + with client.mock_post(make_response({"pendingRef": "ref1", "linkId": "24"})) as mock_post: + await client.invoke(client.enchantedlink.sign_in("dummy@dummy.com", "http://r.me", lo, "refresh")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:refresh", + "x-descope-project-id": PROJECT_ID, }, - ) - self.assertEqual( - EnchantedLink._compose_verify_body("t1"), - {"token": "t1"}, - ) - - self.assertEqual( - EnchantedLink._compose_update_user_email_body("id1", "email1", True, False), - { - "loginId": "id1", - "email": "email1", - "addToLoginIDs": True, - "onMergeUseExisting": False, + params=None, + json={ + "loginId": "dummy@dummy.com", + "URI": "http://r.me", + "loginOptions": {"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, }, + follow_redirects=False, ) - self.assertEqual( - EnchantedLink._compose_get_session_body("pending_ref1"), - {"pendingRef": "pending_ref1"}, - ) - - self.assertEqual( - EnchantedLink._compose_get_session_body("pending_ref1"), - {"pendingRef": "pending_ref1"}, - ) - - def test_sign_in(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - # Test failed flows - with self.assertRaises(AuthException): - enchantedlink.sign_in("", "http://test.me") - data = json.loads("""{"pendingRef": "aaaa","linkId":"24"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.sign_in("dummy@dummy.com", "http://test.me") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": {}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res["pendingRef"], "aaaa") - self.assertEqual(res["linkId"], "24") - - # Validate refresh token used while provided - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - enchantedlink.sign_in( - "dummy@dummy.com", - "http://test.me", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # With template options - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - enchantedlink.sign_in( - "dummy@dummy.com", - "http://test.me", - LoginOptions( - stepup=True, - template_options={"blue": "bla"}, - template_id="foo", - revoke_other_sessions=True, - ), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": True, - "customClaims": None, - "templateOptions": {"blue": "bla"}, - "templateId": "foo", - "revokeOtherSessions": True, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sign_in_with_login_options(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"pendingRef": "aaaa", "linkId":"24"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - enchantedlink.sign_in("dummy@dummy.com", "http://test.me", lo, "refresh") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": True, - "customClaims": {"k1": "v1"}, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sign_up(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_up(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + user = {"name": "John", "email": "dummy@dummy.com"} + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_up(None, "http://r.me", user)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_up("", "http://r.me", user)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_up("dummy@dummy.com", "http://r.me", user)) + + # Success + with client.mock_post(make_response({"pendingRef": "ref123", "linkId": "lnk1"})): + result = await client.invoke(client.enchantedlink.sign_up("dummy@dummy.com", "http://r.me", user)) + assert result is not None + + async def test_sign_up_or_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.sign_up_or_in("dummy@dummy.com", "http://r.me")) + + # Success + payload + with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: + result = await client.invoke(client.enchantedlink.sign_up_or_in("dummy@dummy.com", "http://r.me")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, ) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - - # Test failed flows - self.assertRaises( - AuthException, - enchantedlink.sign_up, - "", - "http://test.me", - {"name": "john"}, - ) - - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.sign_up( - "dummy@dummy.com", - "http://test.me", - {"username": "user1", "email": "dummy@dummy.com"}, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": {"username": "user1", "email": "dummy@dummy.com"}, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res["pendingRef"], "aaaa") - # Test user is None so using the login_id as default - with patch("httpx.post") as mock_post: - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.sign_up( - "dummy@dummy.com", - "http://test.me", - None, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": {"email": "dummy@dummy.com"}, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res["pendingRef"], "aaaa") + async def test_get_session(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.sign_up( - "dummy@dummy.com", - "http://test.me", - None, - SignUpOptions( - template_options={"bla": "blue"}, - template_id="foo", - revoke_other_sessions=True, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": {"email": "dummy@dummy.com"}, - "email": "dummy@dummy.com", - "loginOptions": { - "templateOptions": {"bla": "blue"}, - "templateId": "foo", - "revokeOtherSessions": True, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res["pendingRef"], "aaaa") + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.get_session("pending-ref")) - def test_sign_up_or_in(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, ) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - enchantedlink.sign_up_or_in( - "dummy@dummy.com", - "http://test.me", - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": {}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - enchantedlink.sign_up_or_in( - "dummy@dummy.com", - "http://test.me", - SignUpOptions(template_options={"bla": "blue"}), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_enchantedlink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": False, - "customClaims": None, - "mfa": False, - "templateOptions": {"bla": "blue"}, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_verify(self): - token = "1234" - - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.enchantedlink.get_session("pending-ref")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.get_session_enchantedlink_auth_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"pendingRef": "pending-ref"}, + follow_redirects=False, ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - enchantedlink.verify, - token, - ) - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {} - mock_post.return_value = my_mock_response - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNone(enchantedlink.verify(token)) - - def test_get_session(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_verify(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.verify("some-token")) + + # Success (returns None) + with client.mock_post(make_response({})) as mock_post: + result = await client.invoke(client.enchantedlink.verify("some-token")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_enchantedlink_auth_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"token": "some-token"}, + follow_redirects=False, ) - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {} - mock_post.return_value = my_mock_response - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone(enchantedlink.get_session("aaaaaa")) - - def test_update_user_email(self): - enchantedlink = EnchantedLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_update_user_email(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_email("", "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_email(None, "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_email("id", "bad-email", refresh_token)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.enchantedlink.update_user_email("id", "new@example.com", refresh_token)) + + # Success + payload + with client.mock_post(make_response({"pendingRef": "ref123"})) as mock_post: + result = await client.invoke( + client.enchantedlink.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_enchantedlink_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "email": "new@example.com", + "addToLoginIDs": False, + "onMergeUseExisting": False, + }, + follow_redirects=False, ) - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - # Test failed flows - self.assertRaises( - AuthException, - enchantedlink.update_user_email, - "", - "dummy@dummy.com", - "refresh_token1", - ) - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.update_user_email("id1", "dummy@dummy.com", "refresh_token1") - self.assertEqual(res["pendingRef"], "aaaa") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_enchantedlink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # with template options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - data = json.loads("""{"pendingRef": "aaaa"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - res = enchantedlink.update_user_email( - "id1", - "dummy@dummy.com", - "refresh_token1", - template_options={"bla": "blue"}, - ) - self.assertEqual(res["pendingRef"], "aaaa") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_enchantedlink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_magiclink.py b/tests/test_magiclink.py index 771025d17..7c9d078ea 100644 --- a/tests/test_magiclink.py +++ b/tests/test_magiclink.py @@ -1,714 +1,249 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest -from descope import SESSION_COOKIE_NAME, AuthException, DeliveryMethod -from descope.auth import Auth -from descope.authmethod.magiclink import MagicLink # noqa: F401 +from descope import AuthException, DeliveryMethod +from descope.authmethod.magiclink import MagicLink from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, - SignUpOptions, ) -from tests.testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestMagicLink(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - +class TestMagicLink: def test_compose_urls(self): - self.assertEqual( - MagicLink._compose_signin_url(DeliveryMethod.SMS), - "/v1/auth/magiclink/signin/sms", - ) - self.assertEqual( - MagicLink._compose_signup_url(DeliveryMethod.WHATSAPP), - "/v1/auth/magiclink/signup/whatsapp", - ) - self.assertEqual( - MagicLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL), - "/v1/auth/magiclink/signup-in/email", - ) - - self.assertEqual( - MagicLink._compose_update_phone_url(DeliveryMethod.SMS), - "/v1/auth/magiclink/update/phone/sms", - ) + assert MagicLink._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/magiclink/signin/sms" + assert MagicLink._compose_signup_url(DeliveryMethod.WHATSAPP) == "/v1/auth/magiclink/signup/whatsapp" + assert MagicLink._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/magiclink/signup-in/email" + assert MagicLink._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/magiclink/update/phone/sms" def test_compose_body(self): - self.assertEqual( - MagicLink._compose_signin_body("id1", "uri1"), - { - "loginId": "id1", - "URI": "uri1", - "loginOptions": {}, - }, - ) + assert MagicLink._compose_signin_body("id1", "uri1") == { + "loginId": "id1", + "URI": "uri1", + "loginOptions": {}, + } + assert MagicLink._compose_verify_body("t1") == {"token": "t1"} + assert MagicLink._compose_update_user_email_body("id1", "email1", True, False) == { + "loginId": "id1", + "email": "email1", + "addToLoginIDs": True, + "onMergeUseExisting": False, + } - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}, template_id="foo") - self.assertEqual( - MagicLink._compose_signin_body("id1", "uri1", lo), - { - "loginId": "id1", - "URI": "uri1", - "loginOptions": { - "stepup": True, - "mfa": False, - "customClaims": {"k1": "v1"}, - "templateId": "foo", - }, + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, "", "http://r.me")) + with pytest.raises(AuthException): + await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, None, "http://r.me")) + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", LoginOptions(mfa=True)) + ) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me")) + + # Success + payload + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + result = await client.invoke( + client.magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_magiclink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, }, - ) - - self.assertEqual( - MagicLink._compose_signup_body(DeliveryMethod.EMAIL, "id1", "uri1", {"email": "email1"}), - { - "loginId": "id1", - "URI": "uri1", - "user": {"email": "email1"}, - "email": "email1", + params=None, + json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, + ) + + async def test_sign_up(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + user = {"name": "John", "email": "dummy@dummy.com"} + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.magiclink.sign_up(DeliveryMethod.EMAIL, None, "http://r.me", user)) + with pytest.raises(AuthException): + await client.invoke(client.magiclink.sign_up(DeliveryMethod.EMAIL, "", "http://r.me", user)) + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.sign_up(DeliveryMethod.EMAIL, "id", "http://r.me", {"email": "not-valid"}) + ) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", user) + ) + + # Success + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): + result = await client.invoke( + client.magiclink.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me", user) + ) + assert result is not None + + async def test_sign_up_or_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") + ) + + # Success + payload + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + result = await client.invoke( + client.magiclink.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://r.me") + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_magiclink_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, }, - ) - self.assertEqual( - MagicLink._compose_verify_body("t1"), - {"token": "t1"}, - ) - - self.assertEqual( - MagicLink._compose_update_user_email_body("id1", "email1", True, False), - { - "loginId": "id1", - "email": "email1", - "addToLoginIDs": True, + params=None, + json={"loginId": "dummy@dummy.com", "URI": "http://r.me", "loginOptions": {}}, + follow_redirects=False, + ) + + async def test_verify(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.magiclink.verify("some-token")) + + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.magiclink.verify("some-token")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_magiclink_auth_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"token": "some-token"}, + follow_redirects=False, + ) + + async def test_update_user_email(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.magiclink.update_user_email("", "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.magiclink.update_user_email(None, "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.magiclink.update_user_email("id", "bad-email", refresh_token)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.magiclink.update_user_email("id", "new@example.com", refresh_token)) + + # Success + payload + with client.mock_post(make_response({"maskedEmail": "ne***@***le.com"})) as mock_post: + result = await client.invoke( + client.magiclink.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_magiclink_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "email": "new@example.com", + "addToLoginIDs": False, "onMergeUseExisting": False, }, - ) - - self.assertEqual( - MagicLink._compose_update_user_phone_body("id1", "+11111111", False, True), - { - "loginId": "id1", - "phone": "+11111111", + follow_redirects=False, + ) + + async def test_update_user_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.update_user_phone(DeliveryMethod.SMS, "", "+11234567890", refresh_token) + ) + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.update_user_phone(DeliveryMethod.SMS, "id", "bad-phone", refresh_token) + ) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.magiclink.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) + ) + + # Success + payload + with client.mock_post(make_response({"maskedPhone": "+1***890"})) as mock_post: + result = await client.invoke( + client.magiclink.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_magiclink_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy", + "phone": "+11234567890", "addToLoginIDs": False, - "onMergeUseExisting": True, + "onMergeUseExisting": False, }, + follow_redirects=False, ) - - def test_sign_in(self): - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - magiclink.sign_in, - DeliveryMethod.EMAIL, - None, - "http://test.me", - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.sign_in, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me"), - ) - - self.assertRaises( - AuthException, - magiclink.sign_in, - DeliveryMethod.EMAIL, - "exid", - "http://test.me", - LoginOptions(mfa=True), - ) - - # Validate refresh token used while provided - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - magiclink.sign_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # With template options - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - magiclink.sign_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - LoginOptions(stepup=True, template_options={"blue": "bla"}, template_id=None), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": True, - "customClaims": None, - "templateOptions": {"blue": "bla"}, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sign_up(self): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - magiclink.sign_up, - DeliveryMethod.EMAIL, - None, - "http://test.me", - signup_user_details, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.sign_up, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - resp = magiclink.sign_up( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ) - self.assertEqual("t***@example.com", resp) - - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - resp = magiclink.sign_up( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - SignUpOptions(template_options={"bla": "blue"}, template_id="foo"), - ) - self.assertEqual("t***@example.com", resp) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - }, - "email": "dummy@dummy.com", - "loginOptions": { - "templateOptions": {"bla": "blue"}, - "templateId": "foo", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test flow where username not set and we used the login_id as default - signup_user_details = { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.sign_up( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - signup_user_details, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - }, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test user is None so using the login_id as default - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.sign_up( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - None, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "user": {"email": "dummy@dummy.com"}, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - def test_sign_up_or_in(self): - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.sign_up_or_in, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com", "http://test.me"), - ) - - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.sign_up_or_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - "http://test.me", - SignUpOptions(template_options={"bla": "blue"}, template_id="foo"), - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_magiclink_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "dummy@dummy.com", - "URI": "http://test.me", - "loginOptions": { - "stepup": False, - "customClaims": None, - "mfa": False, - "templateOptions": {"bla": "blue"}, - "templateId": "foo", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - def test_verify(self): - token = "1234" - - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.verify, - token, - ) - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {} - mock_post.return_value = my_mock_response - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone(magiclink.verify(token)) - - def test_verify_with_get_keys_mock(self): - token = "1234" - magiclink = MagicLink( - Auth(self.dummy_project_id, None, http_client=self.make_http_client()) - ) # public key will be "fetched" by Get mock - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.get") as mock_get: - mock_get.return_value.text = json.dumps({"keys": [self.public_key_dict]}) - mock_get.return_value.is_success = True - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {} - mock_post.return_value = my_mock_response - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone(magiclink.verify(token)) - - def test_update_user_email(self): - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - self.assertRaises( - AuthException, - magiclink.update_user_email, - "", - "dummy@dummy.com", - "refresh_token1", - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.update_user_email, - "id1", - "dummy@dummy.com", - "refresh_token1", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.update_user_email("id1", "dummy@dummy.com", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test success flow with template options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - magiclink.update_user_email( - "id1", - "dummy@dummy.com", - "refresh_token1", - template_options={"bla": "blue"}, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - def test_update_user_phone(self): - magiclink = MagicLink( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - self.assertRaises( - AuthException, - magiclink.update_user_phone, - DeliveryMethod.EMAIL, - "", - "+11111111", - "refresh_token1", - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - magiclink.update_user_phone, - DeliveryMethod.EMAIL, - "id1", - "+11111111", - "refresh_token1", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****1111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****1111", - magiclink.update_user_phone(DeliveryMethod.SMS, "id1", "+11111111", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+11111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test success flow with template options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****1111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****1111", - magiclink.update_user_phone( - DeliveryMethod.SMS, - "id1", - "+11111111", - "refresh_token1", - template_options={"bla": "blue"}, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_magiclink_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+11111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_oauth.py b/tests/test_oauth.py index ac944e810..f15d72a80 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -1,180 +1,125 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth -from descope.authmethod.oauth import OAuth -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1, LoginOptions -from tests.testutils import SSLMatcher +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestOAuth(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", - } - +class TestOAuth: def test_compose_start_params(self): - self.assertEqual( - OAuth._compose_start_params("google", "http://example.com"), - {"provider": "google", "redirectURL": "http://example.com"}, - ) + from descope.authmethod.oauth import OAuth - def test_verify_oauth_providers(self): - self.assertEqual( - OAuth._verify_provider(""), - False, + assert OAuth._compose_start_params("google", "http://example.com") == { + "provider": "google", + "redirectURL": "http://example.com", + } + assert OAuth._compose_start_params("google") == {"provider": "google"} + + def test_verify_provider(self): + from descope.authmethod.oauth import OAuth + + assert OAuth._verify_provider("") is False + assert OAuth._verify_provider(None) is False + assert OAuth._verify_provider("google") is True + + async def test_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors — no HTTP call made + with pytest.raises(AuthException): + await client.invoke(client.oauth.start("")) + with pytest.raises(AuthException): + await client.invoke(client.oauth.start(None)) + with pytest.raises(AuthException): + await client.invoke(client.oauth.start("facebook", login_options=LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.oauth.start("google")) + + # Success + with client.mock_post(make_response({"url": "http://auth.example.com"})): + result = await client.invoke(client.oauth.start("google")) + assert result is not None + + # Verify payload with params + with client.mock_post(make_response({"url": "http://auth.example.com"})) as mock_post: + await client.invoke(client.oauth.start("facebook")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params={"provider": "facebook"}, + json={}, + follow_redirects=False, ) - self.assertEqual( - OAuth._verify_provider(None), - False, + async def test_start_with_login_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = "dummy-refresh" + + lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) + with client.mock_post(make_response({})) as mock_post: + await client.invoke(client.oauth.start("facebook", login_options=lo, refresh_token=refresh_token)) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params={"provider": "facebook"}, + json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, + follow_redirects=False, ) - def test_oauth_start(self): - oauth = OAuth( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) + async def test_exchange_token(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - # Test failed flows - self.assertRaises(AuthException, oauth.start, "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, oauth.start, "google") - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(oauth.start("google")) - - self.assertRaises( - AuthException, - oauth.start, - "facebook", - "http://test.me", - LoginOptions(mfa=True), - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - oauth.start("facebook") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params={"provider": "facebook"}, - json={}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_oauth_start_with_login_options(self): - oauth = OAuth( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.oauth.exchange_token("")) + with pytest.raises(AuthException): + await client.invoke(client.oauth.exchange_token(None)) - # Test failed flows - self.assertRaises(AuthException, oauth.start, "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, oauth.start, "google") - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(oauth.start("google")) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - oauth.start("facebook", login_options=lo, refresh_token="refresh") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh", - "x-descope-project-id": self.dummy_project_id, - }, - params={"provider": "facebook"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_compose_exchange_params(self): - self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) - - def test_exchange_token(self): - oauth = OAuth( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.oauth.exchange_token("c1")) - # Test failed flows - self.assertRaises(AuthException, oauth.exchange_token, "") - self.assertRaises(AuthException, oauth.exchange_token, None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, oauth.exchange_token, "c1") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - oauth.exchange_token("c1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - -if __name__ == "__main__": - unittest.main() + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.oauth.exchange_token("c1")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.oauth_exchange_token_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"code": "c1"}, + follow_redirects=False, + ) diff --git a/tests/test_otp.py b/tests/test_otp.py index 0c6816742..baee1c539 100644 --- a/tests/test_otp.py +++ b/tests/test_otp.py @@ -1,793 +1,246 @@ -from enum import Enum -from unittest import mock -from unittest.mock import patch +import pytest -from descope import SESSION_COOKIE_NAME, AuthException, DeliveryMethod, DescopeClient -from descope.authmethod.otp import OTP # noqa: F401 +from descope import AuthException, DeliveryMethod +from descope.authmethod.otp import OTP from descope.common import ( - DEFAULT_TIMEOUT_SECONDS, REFRESH_SESSION_COOKIE_NAME, EndpointsV1, LoginOptions, - SignUpOptions, ) -from tests.testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestOTP(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - +class TestOTP: def test_compose_signin_url(self): - self.assertEqual( - OTP._compose_signin_url(DeliveryMethod.EMAIL), - "/v1/auth/otp/signin/email", - ) - self.assertEqual( - OTP._compose_signin_url(DeliveryMethod.SMS), - "/v1/auth/otp/signin/sms", - ) - self.assertEqual( - OTP._compose_signin_url(DeliveryMethod.VOICE), - "/v1/auth/otp/signin/voice", - ) - self.assertEqual( - OTP._compose_signin_url(DeliveryMethod.WHATSAPP), - "/v1/auth/otp/signin/whatsapp", - ) + assert OTP._compose_signin_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signin/email" + assert OTP._compose_signin_url(DeliveryMethod.SMS) == "/v1/auth/otp/signin/sms" + assert OTP._compose_signin_url(DeliveryMethod.VOICE) == "/v1/auth/otp/signin/voice" + assert OTP._compose_signin_url(DeliveryMethod.WHATSAPP) == "/v1/auth/otp/signin/whatsapp" def test_compose_verify_code_url(self): - self.assertEqual( - OTP._compose_verify_code_url(DeliveryMethod.EMAIL), - "/v1/auth/otp/verify/email", - ) - self.assertEqual( - OTP._compose_verify_code_url(DeliveryMethod.SMS), - "/v1/auth/otp/verify/sms", - ) - self.assertEqual( - OTP._compose_verify_code_url(DeliveryMethod.VOICE), - "/v1/auth/otp/verify/voice", - ) - self.assertEqual( - OTP._compose_verify_code_url(DeliveryMethod.WHATSAPP), - "/v1/auth/otp/verify/whatsapp", - ) - - def test_compose_update_phone_url(self): - self.assertEqual( - OTP._compose_update_phone_url(DeliveryMethod.EMAIL), - "/v1/auth/otp/update/phone/email", - ) - self.assertEqual( - OTP._compose_update_phone_url(DeliveryMethod.SMS), - "/v1/auth/otp/update/phone/sms", - ) - self.assertEqual( - OTP._compose_update_phone_url(DeliveryMethod.VOICE), - "/v1/auth/otp/update/phone/voice", - ) - self.assertEqual( - OTP._compose_update_phone_url(DeliveryMethod.WHATSAPP), - "/v1/auth/otp/update/phone/whatsapp", - ) + assert OTP._compose_verify_code_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/verify/email" + assert OTP._compose_verify_code_url(DeliveryMethod.SMS) == "/v1/auth/otp/verify/sms" def test_compose_sign_up_or_in_url(self): - self.assertEqual( - OTP._compose_sign_up_or_in_url(DeliveryMethod.EMAIL), - "/v1/auth/otp/signup-in/email", - ) - self.assertEqual( - OTP._compose_sign_up_or_in_url(DeliveryMethod.SMS), - "/v1/auth/otp/signup-in/sms", - ) - self.assertEqual( - OTP._compose_sign_up_or_in_url(DeliveryMethod.VOICE), - "/v1/auth/otp/signup-in/voice", - ) - self.assertEqual( - OTP._compose_sign_up_or_in_url(DeliveryMethod.WHATSAPP), - "/v1/auth/otp/signup-in/whatsapp", - ) + assert OTP._compose_sign_up_or_in_url(DeliveryMethod.EMAIL) == "/v1/auth/otp/signup-in/email" + assert OTP._compose_sign_up_or_in_url(DeliveryMethod.SMS) == "/v1/auth/otp/signup-in/sms" - def test_compose_update_user_phone_body(self): - self.assertEqual( - OTP._compose_update_user_phone_body("dummy@dummy.com", "+11111111", False, True), - { + def test_compose_update_phone_url(self): + assert OTP._compose_update_phone_url(DeliveryMethod.SMS) == "/v1/auth/otp/update/phone/sms" + assert OTP._compose_update_phone_url(DeliveryMethod.WHATSAPP) == "/v1/auth/otp/update/phone/whatsapp" + + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "")) + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, None)) + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "id", LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) + + # Success + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): + result = await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) + assert result is not None + + # Verify payload + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + await client.invoke(client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_otp_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "loginOptions": {}}, + follow_redirects=False, + ) + + async def test_sign_up(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + user = {"name": "John", "email": "dummy@dummy.com"} + + # Validation errors — empty login_id returns False from adjust_and_verify + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, None, user)) + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "", user)) + # Bad email in user dict + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "id", {"email": "not-valid"})) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user)) + + # Success + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})): + result = await client.invoke(client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", user)) + assert result is not None + + async def test_sign_up_or_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "")) + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) + + # Success + with client.mock_post(make_response({"maskedEmail": "du***@***my.com"})) as mock_post: + result = await client.invoke(client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_otp_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "loginOptions": {}}, + follow_redirects=False, + ) + + async def test_verify_code(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "", "123456")) + with pytest.raises(AuthException): + await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, None, "123456")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", "123456")) + + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", "123456")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.verify_code_auth_path}/email", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "code": "123456"}, + follow_redirects=False, + ) + + async def test_update_user_email(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_email("", "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_email(None, "new@example.com", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_email("id", "not-valid-email", refresh_token)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_email("dummy@dummy.com", "new@example.com", refresh_token)) + + # Success + with client.mock_post(make_response({"maskedEmail": "ne***@***le.com"})) as mock_post: + result = await client.invoke( + client.otp.update_user_email("dummy@dummy.com", "new@example.com", refresh_token) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ "loginId": "dummy@dummy.com", - "phone": "+11111111", + "email": "new@example.com", "addToLoginIDs": False, - "onMergeUseExisting": True, + "onMergeUseExisting": False, }, - ) - - def test_compose_update_user_email_body(self): - self.assertEqual( - OTP._compose_update_user_email_body("dummy@dummy.com", "dummy@dummy.com", False, True), - { - "loginId": "dummy@dummy.com", - "email": "dummy@dummy.com", + follow_redirects=False, + ) + + async def test_update_user_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_phone(DeliveryMethod.SMS, "", "+11234567890", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.otp.update_user_phone(DeliveryMethod.SMS, "id", "not-a-phone", refresh_token)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.otp.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) + ) + + # Success + with client.mock_post(make_response({"maskedPhone": "+1***890"})) as mock_post: + result = await client.invoke( + client.otp.update_user_phone(DeliveryMethod.SMS, "dummy", "+11234567890", refresh_token) + ) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/sms", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy", + "phone": "+11234567890", "addToLoginIDs": False, - "onMergeUseExisting": True, + "onMergeUseExisting": False, }, + follow_redirects=False, ) - - def test_sign_up(self): - invalid_signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy", - } - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.EMAIL, - "dummy@dummy", - invalid_signup_user_details, - ) - invalid_signup_user_details["email"] = "dummy@dummy.com" # set valid mail - invalid_signup_user_details["phone"] = "aaaaaaaa" # set invalid phone - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.EMAIL, - "", - invalid_signup_user_details, - ) - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.SMS, - "dummy@dummy.com", - invalid_signup_user_details, - ) - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.VOICE, - "dummy@dummy.com", - invalid_signup_user_details, - ) - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.WHATSAPP, - "dummy@dummy.com", - invalid_signup_user_details, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.sign_up, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - signup_user_details, - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details), - ) - - # Test flow where username set as empty and we used the login_id as default - signup_user_details = { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - } - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", signup_user_details), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "user": { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - }, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - signup_user_details, - SignUpOptions(template_options={"bla": "blue"}, template_id="foo"), - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "user": { - "username": "", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - }, - "email": "dummy@dummy.com", - "loginOptions": { - "templateOptions": {"bla": "blue"}, - "templateId": "foo", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test user is None so using the login_id as default - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up(DeliveryMethod.EMAIL, "dummy@dummy.com", None), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "user": {"email": "dummy@dummy.com"}, - "email": "dummy@dummy.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # test undefined enum value - class Dummy(Enum): - DUMMY = 7 - - self.assertRaises(AuthException, OTP._compose_signin_url, Dummy.DUMMY) - - def test_sign_in(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises(AuthException, client.otp.sign_in, DeliveryMethod.EMAIL, "") - self.assertRaises(AuthException, client.otp.sign_in, DeliveryMethod.EMAIL, None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.sign_in, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_in(DeliveryMethod.EMAIL, "dummy@dummy.com"), - ) - self.assertRaises( - AuthException, - client.otp.sign_in, - DeliveryMethod.EMAIL, - "exid", - LoginOptions(mfa=True), - ) - - # Validate refresh token used while provided - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - client.otp.sign_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - LoginOptions(stepup=True), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # With template options - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - client.otp.sign_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - LoginOptions(stepup=True, template_options={"blue": "bla"}, template_id="foo"), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "loginOptions": { - "stepup": True, - "customClaims": None, - "templateOptions": {"blue": "bla"}, - "templateId": "foo", - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # With locale - with patch("httpx.post") as mock_post: - refresh_token = "dummy refresh token" - client.otp.sign_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - LoginOptions(stepup=True, locale="en-US"), - refresh_token=refresh_token, - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{refresh_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "loginOptions": { - "stepup": True, - "customClaims": None, - "mfa": False, - "locale": "en-US", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sign_up_or_in(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises(AuthException, client.otp.sign_up_or_in, DeliveryMethod.EMAIL, "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.sign_up_or_in, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up_or_in(DeliveryMethod.EMAIL, "dummy@dummy.com"), - ) - - # Test success flow with sign up options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.sign_up_or_in( - DeliveryMethod.EMAIL, - "dummy@dummy.com", - SignUpOptions(template_options={"bla": "blue"}, template_id="foo"), - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_otp_path}/email", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "dummy@dummy.com", - "loginOptions": { - "stepup": False, - "customClaims": None, - "mfa": False, - "templateOptions": {"bla": "blue"}, - "templateId": "foo", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - def test_verify_code(self): - code = "1234" - - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - self.assertRaises(AuthException, client.otp.verify_code, DeliveryMethod.EMAIL, "", code) - self.assertRaises(AuthException, client.otp.verify_code, DeliveryMethod.EMAIL, None, code) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.verify_code, - DeliveryMethod.EMAIL, - "dummy@dummy.com", - code, - ) - - # Test success flow - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL" - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {} - mock_post.return_value = my_mock_response - mock_post.return_value.cookies = { - SESSION_COOKIE_NAME: "dummy session token", - REFRESH_SESSION_COOKIE_NAME: valid_jwt_token, - } - self.assertIsNotNone(client.otp.verify_code(DeliveryMethod.EMAIL, "dummy@dummy.com", code)) - - def test_update_user_email(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.otp.update_user_email, - "", - "dummy@dummy.com", - "refresh_token1", - ) - - self.assertRaises( - AuthException, - client.otp.update_user_email, - "id1", - "dummy@dummy", - "refresh_token1", - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.update_user_email, - "id1", - "dummy@dummy.com", - "refresh_token1", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.update_user_email("id1", "dummy@dummy.com", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test success flow with template options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedEmail": "t***@example.com"} - mock_post.return_value = my_mock_response - self.assertEqual( - "t***@example.com", - client.otp.update_user_email( - "id1", - "dummy@dummy.com", - "refresh_token1", - template_options={"bla": "blue"}, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_email_otp_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "email": "dummy@dummy.com", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - def test_update_user_phone(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict) - - # Test failed flows - self.assertRaises( - AuthException, - client.otp.update_user_phone, - DeliveryMethod.SMS, - "", - "+1111111", - "refresh_token1", - ) - self.assertRaises( - AuthException, - client.otp.update_user_phone, - DeliveryMethod.SMS, - "id1", - "not_a_phone", - "refresh_token1", - ) - self.assertRaises( - AuthException, - client.otp.update_user_phone, - DeliveryMethod.EMAIL, - "id1", - "+1111111", - "refresh_token1", - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.otp.update_user_phone, - DeliveryMethod.SMS, - "id1", - "+1111111", - "refresh_token1", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****111", - client.otp.update_user_phone(DeliveryMethod.SMS, "id1", "+1111111", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+1111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****111", - client.otp.update_user_phone(DeliveryMethod.VOICE, "id1", "+1111111", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/voice", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+1111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****111", - client.otp.update_user_phone(DeliveryMethod.WHATSAPP, "id1", "+1111111", "refresh_token1"), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/whatsapp", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+1111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) - - # Test success flow with template options - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = {"maskedPhone": "*****111"} - mock_post.return_value = my_mock_response - self.assertEqual( - "*****111", - client.otp.update_user_phone( - DeliveryMethod.SMS, - "id1", - "+1111111", - "refresh_token1", - template_options={"bla": "blue"}, - ), - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_user_phone_otp_path}/sms", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh_token1", - "x-descope-project-id": self.dummy_project_id, - }, - json={ - "loginId": "id1", - "phone": "+1111111", - "addToLoginIDs": False, - "onMergeUseExisting": False, - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - params=None, - ) diff --git a/tests/test_password.py b/tests/test_password.py index cf5897220..e0d952a69 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,526 +1,253 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth -from descope.authmethod.password import Password # noqa: F401 -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1 -from tests.testutils import SSLMatcher +from descope.authmethod.password import Password +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestPassword(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", +class TestPassword: + def test_compose_signup_body(self): + assert Password._compose_signup_body("id1", "pw1", {"name": "John"}) == { + "loginId": "id1", + "password": "pw1", + "user": {"name": "John"}, } - - def test_sign_up(self): - signup_user_details = { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", + assert Password._compose_signup_body("id1", "pw1", None) == { + "loginId": "id1", + "password": "pw1", } - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - password.sign_up, - "", - None, - signup_user_details, - ) - - self.assertRaises( - AuthException, - password.sign_up, - None, - None, - signup_user_details, - ) - - self.assertRaises( - AuthException, - password.sign_up, - "login_id", - "", - signup_user_details, - ) - - self.assertRaises( - AuthException, - password.sign_up, - "login_id", - None, - signup_user_details, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - password.sign_up, - "dummy@dummy.com", - "123456", - signup_user_details, - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - - self.assertIsNotNone(password.sign_up("dummy@dummy.com", "123456", signup_user_details)) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "password": "123456", - "user": { - "username": "jhon", - "name": "john", - "phone": "972525555555", - "email": "dummy@dummy.com", - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sign_in(self): - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_up(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.password.sign_up("", "pw1")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_up(None, "pw1")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_up("id", "")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_up("id", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.sign_up("dummy@dummy.com", "pw123")) + + # Success + payload + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.password.sign_up("dummy@dummy.com", "pw123")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_password_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "password": "pw123"}, + follow_redirects=False, + ) + + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.password.sign_in("", "pw1")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_in(None, "pw1")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_in("id", "")) + with pytest.raises(AuthException): + await client.invoke(client.password.sign_in("id", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.sign_in("dummy@dummy.com", "pw123")) + + # Success + payload + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.password.sign_in("dummy@dummy.com", "pw123")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_password_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "password": "pw123"}, + follow_redirects=False, + ) + + async def test_send_reset(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.password.send_reset("")) + with pytest.raises(AuthException): + await client.invoke(client.password.send_reset(None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.send_reset("dummy@dummy.com")) + + # Success + payload + with client.mock_post( + make_response({"resetMethod": "magiclink", "maskedEmail": "du***@***my.com"}) + ) as mock_post: + result = await client.invoke(client.password.send_reset("dummy@dummy.com", "https://redirect.here.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.send_reset_password_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "redirectUrl": "https://redirect.here.com"}, + follow_redirects=False, + ) + + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.password.update("", "newpw", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.password.update(None, "newpw", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.password.update("id", "", refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.password.update("id", None, refresh_token)) + with pytest.raises(AuthException): + await client.invoke(client.password.update("id", "newpw", "")) + with pytest.raises(AuthException): + await client.invoke(client.password.update("id", "newpw", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.update("dummy@dummy.com", "newpw", refresh_token)) + + # Success (returns None) + with client.mock_post(make_response({})) as mock_post: + result = await client.invoke(client.password.update("dummy@dummy.com", "newpw", refresh_token)) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_password_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "dummy@dummy.com", "newPassword": "newpw"}, + follow_redirects=False, + ) + + async def test_replace(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.password.replace("", "oldpw", "newpw")) + with pytest.raises(AuthException): + await client.invoke(client.password.replace(None, "oldpw", "newpw")) + with pytest.raises(AuthException): + await client.invoke(client.password.replace("id", "", "newpw")) + with pytest.raises(AuthException): + await client.invoke(client.password.replace("id", None, "newpw")) + with pytest.raises(AuthException): + await client.invoke(client.password.replace("id", "oldpw", "")) + with pytest.raises(AuthException): + await client.invoke(client.password.replace("id", "oldpw", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.replace("dummy@dummy.com", "oldpw", "newpw")) + + # Success + payload + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.password.replace("dummy@dummy.com", "oldpw", "newpw")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.replace_password_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "dummy@dummy.com", + "oldPassword": "oldpw", + "newPassword": "newpw", + }, + follow_redirects=False, + ) + + async def test_get_policy(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # HTTP error + with client.mock_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.password.get_policy()) + + # Success + payload + with client.mock_get(make_response({"minLength": 8, "lowercase": True})) as mock_get: + result = await client.invoke(client.password.get_policy()) + assert result is not None + assert_http_called( + mock_get, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.password_policy_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + follow_redirects=True, ) - - # Test failed flows - self.assertRaises( - AuthException, - password.sign_in, - "", - None, - ) - - self.assertRaises( - AuthException, - password.sign_in, - None, - None, - ) - - self.assertRaises( - AuthException, - password.sign_in, - "login_id", - "", - ) - - self.assertRaises( - AuthException, - password.sign_in, - "login_id", - None, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - password.sign_in, - "dummy@dummy.com", - "123456", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - - self.assertIsNotNone(password.sign_in("dummy@dummy.com", "123456")) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "password": "123456", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_send_reset(self): - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - password.send_reset, - "", - ) - - self.assertRaises( - AuthException, - password.send_reset, - None, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - password.send_reset, - "dummy@dummy.com", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads("""{"resetMethod": "magiclink", "maskedEmail": "du***@***my.com"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - - self.assertIsNotNone(password.send_reset("dummy@dummy.com", "https://redirect.here.com")) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.send_reset_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "redirectUrl": "https://redirect.here.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test success flow with template options - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads("""{"resetMethod": "magiclink", "maskedEmail": "du***@***my.com"}""") - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - - self.assertIsNotNone( - password.send_reset( - "dummy@dummy.com", - "https://redirect.here.com", - {"bla": "blue"}, - ) - ) - - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.send_reset_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "redirectUrl": "https://redirect.here.com", - "templateOptions": {"bla": "blue"}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_update(self): - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - password.update, - "", - None, - None, - ) - - self.assertRaises( - AuthException, - password.update, - None, - None, - None, - ) - - self.assertRaises( - AuthException, - password.update, - "login_id", - "", - None, - ) - - self.assertRaises( - AuthException, - password.update, - "login_id", - None, - None, - ) - - self.assertRaises( - AuthException, - password.update, - "login_id", - "123456", - "", - ) - - self.assertRaises( - AuthException, - password.update, - "login_id", - "123456", - None, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - password.update, - "dummy@dummy.com", - "1234567", - "refresh_token", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - self.assertIsNone(password.update("dummy@dummy.com", "123456", valid_jwt_token)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{valid_jwt_token}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "newPassword": "123456", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_replace(self): - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises( - AuthException, - password.replace, - "", - None, - None, - ) - - self.assertRaises( - AuthException, - password.replace, - None, - None, - None, - ) - - self.assertRaises( - AuthException, - password.replace, - "login_id", - "", - None, - ) - - self.assertRaises( - AuthException, - password.replace, - "login_id", - None, - None, - ) - - self.assertRaises( - AuthException, - password.replace, - "login_id", - "123456", - "", - ) - - self.assertRaises( - AuthException, - password.replace, - "login_id", - "123456", - None, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - password.replace, - "dummy@dummy.com", - "123456", - "1234567", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["test@company.com"], "name": "", "email": "test@company.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - - jwt_response = password.replace("dummy@dummy.com", "123456", "1234567") - self.assertIsNotNone(jwt_response) - self.assertIsNotNone(jwt_response["user"]) - self.assertEqual(jwt_response["user"]["loginIds"], ["test@company.com"]) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.replace_password_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "dummy@dummy.com", - "oldPassword": "123456", - "newPassword": "1234567", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_policy(self): - password = Password( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - password.get_policy, - ) - - # Test success flow - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = True - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads("""{"minLength": 8, "lowercase": true}""") - my_mock_response.json.return_value = data - mock_get.return_value = my_mock_response - self.assertIsNotNone(password.get_policy()) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.password_policy_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) diff --git a/tests/test_saml.py b/tests/test_saml.py index a333308c6..8284d42cf 100644 --- a/tests/test_saml.py +++ b/tests/test_saml.py @@ -1,174 +1,120 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth -from descope.authmethod.saml import SAML -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1, LoginOptions -from tests.testutils import SSLMatcher +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestSAML(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", +class TestSAML: + def test_compose_start_params(self): + from descope.authmethod.saml import SAML + + assert SAML._compose_start_params("tenant1", "http://dummy.com") == { + "tenant": "tenant1", + "redirectURL": "http://dummy.com", } - def test_compose_start_params(self): - self.assertEqual( - SAML._compose_start_params("tenant1", "http://dummy.com"), - {"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + async def test_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.saml.start("", "http://dummy.com")) + with pytest.raises(AuthException): + await client.invoke(client.saml.start(None, "http://dummy.com")) + with pytest.raises(AuthException): + await client.invoke(client.saml.start("tenant1", "")) + with pytest.raises(AuthException): + await client.invoke(client.saml.start("tenant1", None)) + with pytest.raises(AuthException): + await client.invoke(client.saml.start("tenant", "http://dummy.com", LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.saml.start("tenant1", "http://dummy.com")) + + # Success + with client.mock_post(make_response({"url": "http://auth.example.com"})): + result = await client.invoke(client.saml.start("tenant1", "http://dummy.com")) + assert result is not None + + # Verify payload + with client.mock_post(make_response({})) as mock_post: + await client.invoke(client.saml.start("tenant1", "http://dummy.com")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + json={}, + follow_redirects=False, ) - def test_saml_start(self): - saml = SAML( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_start_with_login_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) + + with client.mock_post(make_response({})) as mock_post: + await client.invoke(client.saml.start("tenant1", "http://dummy.com", lo, "refresh")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:refresh", + "x-descope-project-id": PROJECT_ID, + }, + params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, saml.start, "", "http://dummy.com") - self.assertRaises(AuthException, saml.start, None, "http://dummy.com") - self.assertRaises(AuthException, saml.start, "tenant1", "") - self.assertRaises(AuthException, saml.start, "tenant1", None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, saml.start, "tenant1", "http://dummy.com") - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(saml.start("tenant1", "http://dummy.com")) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - saml.start("tenant1", "http://dummy.com") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertRaises( - AuthException, - saml.start, - "tenant", - "http://dummy.com", - LoginOptions(mfa=True), - ) - - def test_saml_start_with_login_options(self): - saml = SAML( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) + async def test_exchange_token(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - # Test failed flows - self.assertRaises(AuthException, saml.start, "", "http://dummy.com") - self.assertRaises(AuthException, saml.start, None, "http://dummy.com") - self.assertRaises(AuthException, saml.start, "tenant1", "") - self.assertRaises(AuthException, saml.start, "tenant1", None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, saml.start, "tenant1", "http://dummy.com") - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(saml.start("tenant1", "http://dummy.com")) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - saml.start("tenant1", "http://dummy.com", lo, "refresh") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_saml_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh", - "x-descope-project-id": self.dummy_project_id, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_compose_exchange_params(self): - self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) - - def test_exchange_token(self): - saml = SAML( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.saml.exchange_token("")) + with pytest.raises(AuthException): + await client.invoke(client.saml.exchange_token(None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.saml.exchange_token("c1")) - # Test failed flows - self.assertRaises(AuthException, saml.exchange_token, "") - self.assertRaises(AuthException, saml.exchange_token, None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, saml.exchange_token, "c1") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - saml.exchange_token("c1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.saml_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - -if __name__ == "__main__": - unittest.main() + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, + ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.saml.exchange_token("c1")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.saml_exchange_token_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"code": "c1"}, + follow_redirects=False, + ) diff --git a/tests/test_sso.py b/tests/test_sso.py index 5cabc30e2..e5d3c1b0d 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -1,311 +1,140 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth -from descope.authmethod.sso import SSO -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1, LoginOptions -from tests.testutils import SSLMatcher +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestSSO(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "2Bt5WLccLUey1Dp7utptZb3Fx9K", - "kty": "EC", - "use": "sig", - "x": "8SMbQQpCQAGAxCdoIz8y9gDw-wXoyoN5ILWpAlBKOcEM1Y7WmRKc1O2cnHggyEVi", - "y": "N5n5jKZA5Wu7_b4B36KKjJf-VRfJ-XqczfCSYy9GeQLqF-b63idfE0SYaYk9cFqg", - } - +class TestSSO: def test_compose_start_params(self): - self.assertEqual( - SSO._compose_start_params("tenant1", "http://dummy.com", "", "", "", None), - {"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - ) + from descope.authmethod.sso import SSO - self.assertEqual( - SSO._compose_start_params("tenant1", "http://dummy.com", "bla", "blue", "", None), - { - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "prompt": "bla", - "ssoId": "blue", - }, - ) + assert SSO._compose_start_params("tenant1", "http://dummy.com", "", "", "", None) == { + "tenant": "tenant1", + "redirectURL": "http://dummy.com", + } + assert SSO._compose_start_params("tenant1", "http://dummy.com", "bla", "blue", "", None) == { + "tenant": "tenant1", + "redirectURL": "http://dummy.com", + "prompt": "bla", + "ssoId": "blue", + } + assert SSO._compose_start_params("t1", "http://x.com", "consent", "sid", "user@d.com", True) == { + "tenant": "t1", + "redirectURL": "http://x.com", + "prompt": "consent", + "ssoId": "sid", + "loginHint": "user@d.com", + "forceAuthn": True, + } + # forceAuthn=False must be included (not skipped as falsy) + assert SSO._compose_start_params("t1", "http://x.com", "", "", "", False) == { + "tenant": "t1", + "redirectURL": "http://x.com", + "forceAuthn": False, + } - # Test new parameters - self.assertEqual( - SSO._compose_start_params( - "tenant1", - "http://dummy.com", - "consent", - "sso-id-123", - "user@domain.com", - True, - ), - { - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "prompt": "consent", - "ssoId": "sso-id-123", - "loginHint": "user@domain.com", - "forceAuthn": True, + async def test_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.sso.start("", "http://dummy.com")) + with pytest.raises(AuthException): + await client.invoke(client.sso.start(None, "http://dummy.com")) + with pytest.raises(AuthException): + await client.invoke(client.sso.start("tenant", "http://dummy.com", LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.sso.start("tenant1", "http://dummy.com")) + + # Success + with client.mock_post(make_response({"url": "http://auth.example.com"})): + result = await client.invoke(client.sso.start("tenant1", "http://dummy.com")) + assert result is not None + + # Verify payload + with client.mock_post(make_response({})) as mock_post: + await client.invoke(client.sso.start("tenant1", "http://dummy.com", sso_id="some-sso-id")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, }, - ) - - # Test boolean parameters set to False - self.assertEqual( - SSO._compose_start_params("tenant1", "http://dummy.com", "", "", "", False), - { + params={ "tenant": "tenant1", "redirectURL": "http://dummy.com", - "forceAuthn": False, + "ssoId": "some-sso-id", }, + json={}, + follow_redirects=False, ) - def test_sso_start(self): - sso = SSO( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises(AuthException, sso.start, "", "http://dummy.com") - self.assertRaises(AuthException, sso.start, None, "http://dummy.com") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, sso.start, "tenant1", "http://dummy.com") - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(sso.start("tenant1", "http://dummy.com")) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - sso.start("tenant1", "http://dummy.com", sso_id="some-sso-id") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params={ - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "ssoId": "some-sso-id", - }, - json={}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertRaises( - AuthException, - sso.start, - "tenant", - "http://dummy.com", - LoginOptions(mfa=True), - ) - - def test_sso_start_with_login_options(self): - sso = SSO( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_start_with_login_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) + + with client.mock_post(make_response({})) as mock_post: + await client.invoke(client.sso.start("tenant1", "http://dummy.com", lo, "refresh")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:refresh", + "x-descope-project-id": PROJECT_ID, + }, + params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, + json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, sso.start, "", "http://dummy.com") - self.assertRaises(AuthException, sso.start, None, "http://dummy.com") + async def test_exchange_token(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, sso.start, "tenant1", "http://dummy.com") + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.sso.exchange_token("")) + with pytest.raises(AuthException): + await client.invoke(client.sso.exchange_token(None)) - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(sso.start("tenant1", "http://dummy.com")) + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.sso.exchange_token("c1")) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - sso.start("tenant1", "http://dummy.com", lo, "refresh") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh", - "x-descope-project-id": self.dummy_project_id, - }, - params={"tenant": "tenant1", "redirectURL": "http://dummy.com"}, - json={"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_sso_start_login_hint(self): - sso = SSO( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, ) - - # Test with new parameters - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - sso.start( - "tenant1", - "http://dummy.com", - login_hint="user@company.com", - force_authn=True, - ) - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params={ - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "loginHint": "user@company.com", - "forceAuthn": True, - }, - json={}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test with boolean parameters set to False - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - sso.start("tenant1", "http://dummy.com", force_authn=False) - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params={ - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "forceAuthn": False, - }, - json={}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - # Test with mixed parameters - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - lo = LoginOptions(stepup=True, custom_claims={"role": "admin"}) - sso.start( - "tenant1", - "http://dummy.com", - lo, - "refresh-token", - "consent", - "sso-config-456", - "user@example.com", - True, - ) - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.auth_sso_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh-token", - "x-descope-project-id": self.dummy_project_id, - }, - params={ - "tenant": "tenant1", - "redirectURL": "http://dummy.com", - "prompt": "consent", - "ssoId": "sso-config-456", - "loginHint": "user@example.com", - "forceAuthn": True, - }, - json={"stepup": True, "customClaims": {"role": "admin"}, "mfa": False}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - def test_compose_exchange_params(self): - self.assertEqual(Auth._compose_exchange_body("c1"), {"code": "c1"}) - - def test_exchange_token(self): - sso = SSO( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.sso.exchange_token("c1")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sso_exchange_token_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"code": "c1"}, + follow_redirects=False, ) - - # Test failed flows - self.assertRaises(AuthException, sso.exchange_token, "") - self.assertRaises(AuthException, sso.exchange_token, None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, sso.exchange_token, "c1") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"jwts": ["eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwMzg4MDc4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MTY2MDIxNTI3OCwiaWF0IjoxNjU3Nzk2MDc4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQnRFSGtnT3UwMmxtTXh6UElleGRNdFV3MU0ifQ.oAnvJ7MJvCyL_33oM7YCF12JlQ0m6HWRuteUVAdaswfnD4rHEBmPeuVHGljN6UvOP4_Cf0559o39UHVgm3Fwb-q7zlBbsu_nP1-PRl-F8NJjvBgC5RsAYabtJq7LlQmh"], "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - sso.exchange_token("c1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{EndpointsV1.sso_exchange_token_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"code": "c1"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 444de1ab5..4fc19b84b 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -1,518 +1,325 @@ -import json -import unittest -from unittest import mock -from unittest.mock import patch +import pytest from descope import AuthException -from descope.auth import Auth from descope.authmethod.webauthn import WebAuthn -from descope.common import DEFAULT_TIMEOUT_SECONDS, EndpointsV1, LoginOptions -from tests.testutils import SSLMatcher +from descope.common import ( + REFRESH_SESSION_COOKIE_NAME, + EndpointsV1, + LoginOptions, +) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT, VALID_REFRESH_TOKEN, VALID_SESSION_TOKEN from . import common -class TestWebauthN(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", +class TestWebAuthn: + def test_compose_sign_up_start_body(self): + assert WebAuthn._compose_sign_up_start_body("dummy@dummy.com", {"name": "dummy"}, "https://example.com") == { + "user": {"loginId": "dummy@dummy.com", "name": "dummy"}, + "origin": "https://example.com", } - def test_compose_signup_body(self): - self.assertEqual( - WebAuthn._compose_sign_up_start_body("dummy@dummy.com", {"name": "dummy"}, "https://example.com"), - { - "user": {"loginId": "dummy@dummy.com", "name": "dummy"}, - "origin": "https://example.com", - }, - ) - - def test_compose_sign_up_in_finish_body(self): - self.assertEqual( - WebAuthn._compose_sign_up_in_finish_body("t01", "response01"), - {"transactionId": "t01", "response": "response01"}, - ) + def test_compose_sign_in_start_body(self): + assert WebAuthn._compose_sign_in_start_body("dummy@dummy.com", "https://example.com") == { + "loginId": "dummy@dummy.com", + "origin": "https://example.com", + "loginOptions": {}, + } - def test_compose_signin_body(self): - self.assertEqual( - WebAuthn._compose_sign_in_start_body("dummy@dummy.com", "https://example.com"), - { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - "loginOptions": {}, - }, - ) + def test_compose_sign_up_or_in_start_body(self): + assert WebAuthn._compose_sign_up_or_in_start_body("dummy@dummy.com", "https://example.com") == { + "loginId": "dummy@dummy.com", + "origin": "https://example.com", + } - def test_compose_signup_or_in_body(self): - self.assertEqual( - WebAuthn._compose_sign_up_or_in_start_body("dummy@dummy.com", "https://example.com"), - { - "loginId": "dummy@dummy.com", - "origin": "https://example.com", - }, - ) + def test_compose_finish_bodies(self): + assert WebAuthn._compose_sign_up_in_finish_body("t01", "resp01") == { + "transactionId": "t01", + "response": "resp01", + } + assert WebAuthn._compose_update_finish_body("t01", "resp01") == { + "transactionId": "t01", + "response": "resp01", + } def test_compose_update_start_body(self): - self.assertEqual( - WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com"), - {"loginId": "dummy@dummy.com", "origin": "https://example.com"}, - ) - - def test_compose_update_finish_body(self): - self.assertEqual( - WebAuthn._compose_update_finish_body("t01", "response01"), - {"transactionId": "t01", "response": "response01"}, - ) + assert WebAuthn._compose_update_start_body("dummy@dummy.com", "https://example.com") == { + "loginId": "dummy@dummy.com", + "origin": "https://example.com", + } - def test_sign_up_start(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_up_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_start("", "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_start("id1", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_start("id1", "https://example.com")) + + # Success + payload + with client.mock_post(make_response({"transactionId": "txn1", "options": "{}"})) as mock_post: + result = await client.invoke(client.webauthn.sign_up_start("id1", "https://example.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"user": {"loginId": "id1"}, "origin": "https://example.com"}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_up_start, "", "https://example.com") - self.assertRaises(AuthException, webauthn.sign_up_start, "id1", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, webauthn.sign_up_start, "id1", "https://example.com") - - # Test success flow - valid_response = json.loads( - """{"transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" + async def test_sign_up_finish(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_finish("", "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_finish(None, "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_finish("t01", "")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_finish("t01", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_finish("t01", "resp")) + + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(webauthn.sign_up_start("id1", "https://example.com")) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - res = webauthn.sign_up_start("id1", "https://example.com") - - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"user": {"loginId": "id1"}, "origin": "https://example.com"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) - - def test_sign_up_finish(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.webauthn.sign_up_finish("t01", "resp01")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_finish_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"transactionId": "t01", "response": "resp01"}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_up_finish, "", "response01") - self.assertRaises(AuthException, webauthn.sign_up_finish, None, "response01") - self.assertRaises(AuthException, webauthn.sign_up_finish, "t01", "") - self.assertRaises(AuthException, webauthn.sign_up_finish, "t01", None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, webauthn.sign_up_finish, "t01", "response01") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"refreshJwt": "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL", - "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, - "firstSeen": false, - "cookieDomain": "test", - "cookiePath": "/", - "cookieMaxAge": 30, - "cookieExpiration": 100 - } - """ - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_auth_webauthn_finish_path}" - webauthn.sign_up_finish("t01", "response01") - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"transactionId": "t01", "response": "response01"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) - - def test_sign_in_start(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_in_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_start("", "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_start("id", "")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_start("id", "https://example.com", LoginOptions(mfa=True))) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com")) + + # Success + payload + with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: + result = await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "id1", "origin": "https://example.com", "loginOptions": {}}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_in_start, "", "https://example.com") - self.assertRaises(AuthException, webauthn.sign_in_start, "id", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - webauthn.sign_in_start, - "id1", - "https://example.com", - ) - - # Test success flow - valid_response = json.loads( - """{"transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" - ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(webauthn.sign_in_start("dummy@dummy.com", "https://example.com")) - self.assertRaises( - AuthException, - webauthn.sign_in_start, - "id", - "origin", - LoginOptions(mfa=True), - ) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - res = webauthn.sign_in_start("id1", "https://example.com") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "id1", - "origin": "https://example.com", - "loginOptions": {}, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) - - def test_sign_in_start_with_login_options(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_in_start_with_login_options(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) + + with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: + await client.invoke(client.webauthn.sign_in_start("id1", "https://example.com", lo, "refresh")) + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:refresh", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "id1", + "origin": "https://example.com", + "loginOptions": {"stepup": True, "customClaims": {"k1": "v1"}, "mfa": False}, + }, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_in_start, "", "https://example.com") - self.assertRaises(AuthException, webauthn.sign_in_start, "id", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - webauthn.sign_in_start, - "id1", - "https://example.com", - ) - - # Test success flow - valid_response = json.loads( - """{"transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" + async def test_sign_in_finish(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_finish("", "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_finish(None, "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_finish("t01", "")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_finish("t01", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_in_finish("t01", "resp")) + + # Success + success_resp = make_response( + {"sessionJwt": VALID_SESSION_TOKEN}, + cookies={REFRESH_SESSION_COOKIE_NAME: VALID_REFRESH_TOKEN}, ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(webauthn.sign_in_start("dummy@dummy.com", "https://example.com")) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - lo = LoginOptions(stepup=True, custom_claims={"k1": "v1"}) - res = webauthn.sign_in_start("id1", "https://example.com", lo, "refresh") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:refresh", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "id1", - "origin": "https://example.com", - "loginOptions": { - "stepup": True, - "customClaims": {"k1": "v1"}, - "mfa": False, - }, - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) - - def test_sign_in_finish(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) - ) - - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_in_finish, "", "response01") - self.assertRaises(AuthException, webauthn.sign_in_finish, None, "response01") - self.assertRaises(AuthException, webauthn.sign_in_finish, "t01", "") - self.assertRaises(AuthException, webauthn.sign_in_finish, "t01", None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, webauthn.sign_in_finish, "t01", "response01") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - - data = json.loads( - """{"refreshJwt": "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL", "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_finish_path}" - webauthn.sign_in_finish("t01", "response01") - - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"transactionId": "t01", "response": "response01"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) - - def test_sign_up_or_in_start(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + with client.mock_post(success_resp) as mock_post: + result = await client.invoke(client.webauthn.sign_in_finish("t01", "resp01")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_in_auth_webauthn_finish_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"transactionId": "t01", "response": "resp01"}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.sign_up_or_in_start, "", "https://example.com") - self.assertRaises(AuthException, webauthn.sign_up_or_in_start, "id", "") - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - webauthn.sign_up_or_in_start, - "id1", - "https://example.com", - ) - - # Test success flow - valid_response = json.loads( - """{"create": true, "transactionId": "2COHI3LIixYhf6Q7EECYt20zyMi", "options": "{'publicKey':{'challenge':'5GOywA7BHL1QceQOfxHKDrasuN8SkbbgXmB5ImVZ+QU=','rp':{'name':'comp6','id':'localhost'},'user':{'name”:”dummy@dummy.com','displayName”:”dummy”,”id':'VTJDT0hJNWlWOHJaZ3VURkpKMzV3bjEydHRkTw=='},'pubKeyCredParams':[{'type':'public-key','alg':-7},{'type':'public-key','alg':-35},{'type':'public-key','alg':-36},{'type':'public-key','alg':-257},{'type':'public-key','alg':-258},{'type':'public-key','alg':-259},{'type':'public-key','alg':-37},{'type':'public-key','alg':-38},{'type':'public-key','alg':-39},{'type':'public-key','alg':-8}],'authenticatorSelection':{'userVerification':'preferred'},'timeout':60000,'attestation':'none'}}"}""" - ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(webauthn.sign_up_or_in_start("dummy@dummy.com", "https://example.com")) - - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - res = webauthn.sign_up_or_in_start("id1", "https://example.com") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_webauthn_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={ - "loginId": "id1", - "origin": "https://example.com", - }, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) - - def test_update_start(self): - valid_jwt_token = "eyJhbGciOiJFUzM4NCIsImtpZCI6IjJCdDVXTGNjTFVleTFEcDd1dHB0WmIzRng5SyIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkVGVuYW50cyI6eyIiOm51bGx9LCJjb29raWVEb21haW4iOiIiLCJjb29raWVFeHBpcmF0aW9uIjoxNjYwNjc5MjA4LCJjb29raWVNYXhBZ2UiOjI1OTE5OTksImNvb2tpZU5hbWUiOiJEU1IiLCJjb29raWVQYXRoIjoiLyIsImV4cCI6MjA5MDA4NzIwOCwiaWF0IjoxNjU4MDg3MjA4LCJpc3MiOiIyQnQ1V0xjY0xVZXkxRHA3dXRwdFpiM0Z4OUsiLCJzdWIiOiIyQzU1dnl4dzBzUkw2RmRNNjhxUnNDRGRST1YifQ.cWP5up4R5xeIl2qoG2NtfLH3Q5nRJVKdz-FDoAXctOQW9g3ceZQi6rZQ-TPBaXMKw68bijN3bLJTqxWW5WHzqRUeopfuzTcMYmC0wP2XGJkrdF6A8D5QW6acSGqglFgu" - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_sign_up_or_in_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_or_in_start("", "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_or_in_start("id", "")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.sign_up_or_in_start("id1", "https://example.com")) + + # Success + payload + with client.mock_post(make_response({"transactionId": "txn1", "create": True})) as mock_post: + result = await client.invoke(client.webauthn.sign_up_or_in_start("id1", "https://example.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.sign_up_or_in_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "id1", "origin": "https://example.com"}, + follow_redirects=False, ) - # Test failed flows - self.assertRaises(AuthException, webauthn.update_start, "", "", "https://example.com") - self.assertRaises(AuthException, webauthn.update_start, None, "", "https://example.com") - self.assertRaises( - AuthException, - webauthn.update_start, - "dummy@dummy.com", - "", - "https://example.com", - ) - self.assertRaises( - AuthException, - webauthn.update_start, - "dummy@dummy.com", - None, - "https://example.com", + async def test_update_start(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + refresh_token = VALID_REFRESH_TOKEN + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_start("", refresh_token, "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_start(None, refresh_token, "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_start("id", "", "https://example.com")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_start("id", None, "https://example.com")) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com")) + + # Success + payload + with client.mock_post(make_response({"transactionId": "txn1"})) as mock_post: + result = await client.invoke(client.webauthn.update_start("id1", refresh_token, "https://example.com")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}:{refresh_token}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "id1", "origin": "https://example.com"}, + follow_redirects=False, ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - webauthn.update_start, - "dummy@dummy.com", - valid_jwt_token, - "https://example.com", - ) - - # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(webauthn.update_start("dummy@dummy.com", valid_jwt_token, "https://example.com")) - - with patch("httpx.post") as mock_post: - valid_response = json.loads("{}") - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.json.return_value = valid_response - mock_post.return_value = my_mock_response - res = webauthn.update_start("dummy@dummy.com", "asdasd", "https://example.com") - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_start_path}" - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:asdasd", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"loginId": "dummy@dummy.com", "origin": "https://example.com"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertEqual(res, valid_response) - - def test_update_finish(self): - webauthn = WebAuthn( - Auth( - self.dummy_project_id, - self.public_key_dict, - http_client=self.make_http_client(), - ) + async def test_update_finish(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT) + + # Validation errors + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_finish("", "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_finish(None, "resp")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_finish("t01", "")) + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_finish("t01", None)) + + # HTTP error + with client.mock_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.webauthn.update_finish("t01", "resp")) + + # Success (returns None) + with client.mock_post(make_response({})) as mock_post: + result = await client.invoke(client.webauthn.update_finish("t01", "resp01")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_finish_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {PROJECT_ID}", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"transactionId": "t01", "response": "resp01"}, + follow_redirects=False, ) - - # Test failed flows - self.assertRaises(AuthException, webauthn.update_finish, "", "response01") - self.assertRaises(AuthException, webauthn.update_finish, None, "response01") - self.assertRaises(AuthException, webauthn.update_finish, "t01", "") - self.assertRaises(AuthException, webauthn.update_finish, "t01", None) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, webauthn.update_finish, "t01", "response01") - - # Test success flow - with patch("httpx.post") as mock_post: - my_mock_response = mock.Mock() - my_mock_response.is_success = True - my_mock_response.cookies = {} - data = json.loads( - """{"refreshJwt": "eyJhbGciOiJFUzM4NCIsImtpZCI6IlAyQ3R6VWhkcXBJRjJ5czlnZzdtczA2VXZ0QzQiLCJ0eXAiOiJKV1QifQ.eyJkcm4iOiJEU1IiLCJleHAiOjIyNjQ0Mzc1OTYsImlhdCI6MTY1OTYzNzU5NiwiaXNzIjoiUDJDdHpVaGRxcElGMnlzOWdnN21zMDZVdnRDNCIsInN1YiI6IlUyQ3UwajBXUHczWU9pUElTSmI1Mkwwd1VWTWcifQ.WLnlHugvzZtrV9OzBB7SjpCLNRvKF3ImFpVyIN5orkrjO2iyAKg_Rb4XHk9sXGC1aW8puYzLbhE1Jv3kk2hDcKggfE8OaRNRm8byhGFZHnvPJwcP_Ya-aRmfAvCLcKOL", "user": {"loginIds": ["guyp@descope.com"], "name": "", "email": "guyp@descope.com", "phone": "", "verifiedEmail": true, "verifiedPhone": false}, "firstSeen": false}""" - ) - my_mock_response.json.return_value = data - mock_post.return_value = my_mock_response - expected_uri = f"{common.DEFAULT_BASE_URL}{EndpointsV1.update_auth_webauthn_finish_path}" - webauthn.update_finish("t01", "response01") - mock_post.assert_called_with( - expected_uri, - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}", - "x-descope-project-id": self.dummy_project_id, - }, - params=None, - json={"transactionId": "t01", "response": "response01"}, - follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) - self.assertIsNotNone(webauthn.sign_up_finish("t01", "response01")) - - -if __name__ == "__main__": - unittest.main() From 6246b504b68a35e9b75e201393d8433c7f96eea6 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:28:17 +0300 Subject: [PATCH 13/17] v1 --- descope/descope_client_async.py | 10 + .../management/_outbound_application_base.py | 69 + descope/management/_tenant_base.py | 31 + descope/management/_user_base.py | 321 ++ descope/management/access_key_async.py | 255 ++ descope/management/audit_async.py | 151 + descope/management/authz_async.py | 385 +++ descope/management/descoper_async.py | 157 + descope/management/fga_async.py | 166 + descope/management/flow_async.py | 246 ++ descope/management/group_async.py | 136 + descope/management/jwt_async.py | 263 ++ descope/management/license_async.py | 21 + descope/management/management_key_async.py | 180 + descope/management/outbound_application.py | 66 +- .../management/outbound_application_async.py | 655 ++++ descope/management/permission_async.py | 210 ++ descope/management/project_async.py | 159 + descope/management/role_async.py | 323 ++ descope/management/sso_application_async.py | 422 +++ descope/management/sso_settings_async.py | 475 +++ descope/management/tenant.py | 33 +- descope/management/tenant_async.py | 369 +++ descope/management/user.py | 328 +- descope/management/user_async.py | 1786 ++++++++++ descope/mgmt_async.py | 162 + tests/conftest.py | 44 +- tests/management/test_access_key.py | 346 +- tests/management/test_audit.py | 164 +- tests/management/test_authz.py | 696 ++-- tests/management/test_descoper.py | 573 ++-- tests/management/test_fga.py | 568 +--- tests/management/test_flow.py | 472 ++- tests/management/test_group.py | 149 +- tests/management/test_jwt.py | 347 +- tests/management/test_license.py | 105 +- tests/management/test_mgmtkey.py | 683 ++-- tests/management/test_outbound_application.py | 1355 +++----- tests/management/test_permission.py | 500 ++- tests/management/test_project.py | 331 +- tests/management/test_role.py | 774 ++--- tests/management/test_sso_application.py | 665 ++-- tests/management/test_sso_settings.py | 771 ++--- tests/management/test_tenant.py | 821 ++--- tests/management/test_user.py | 2919 ++++++++--------- 45 files changed, 12004 insertions(+), 7658 deletions(-) create mode 100644 descope/management/_outbound_application_base.py create mode 100644 descope/management/_tenant_base.py create mode 100644 descope/management/_user_base.py create mode 100644 descope/management/access_key_async.py create mode 100644 descope/management/audit_async.py create mode 100644 descope/management/authz_async.py create mode 100644 descope/management/descoper_async.py create mode 100644 descope/management/fga_async.py create mode 100644 descope/management/flow_async.py create mode 100644 descope/management/group_async.py create mode 100644 descope/management/jwt_async.py create mode 100644 descope/management/license_async.py create mode 100644 descope/management/management_key_async.py create mode 100644 descope/management/outbound_application_async.py create mode 100644 descope/management/permission_async.py create mode 100644 descope/management/project_async.py create mode 100644 descope/management/role_async.py create mode 100644 descope/management/sso_application_async.py create mode 100644 descope/management/sso_settings_async.py create mode 100644 descope/management/tenant_async.py create mode 100644 descope/management/user_async.py create mode 100644 descope/mgmt_async.py diff --git a/descope/descope_client_async.py b/descope/descope_client_async.py index 11443df31..4e496c3e7 100644 --- a/descope/descope_client_async.py +++ b/descope/descope_client_async.py @@ -24,6 +24,7 @@ AuthException, ) from descope.http_client_async import HTTPClientAsync +from descope.mgmt_async import MGMTAsync class DescopeClientAsync(DescopeClientBase): @@ -90,6 +91,11 @@ def __init__( verbose=verbose, ) self._fga_cache_url = fga_cache_url + self._mgmt = MGMTAsync( + http_client=self._mgmt_http, + auth=self._auth, + fga_cache_url=fga_cache_url, + ) self._magiclink = MagicLinkAsync(self._auth, self._auth_http) self._enchantedlink = EnchantedLinkAsync(self._auth, self._auth_http) @@ -141,6 +147,10 @@ def webauthn(self) -> WebAuthnAsync: def password(self) -> PasswordAsync: return self._password + @property + def mgmt(self) -> MGMTAsync: + return self._mgmt + async def aclose(self) -> None: """Close the underlying async HTTP clients and release connections.""" await self._auth_http.aclose() diff --git a/descope/management/_outbound_application_base.py b/descope/management/_outbound_application_base.py new file mode 100644 index 000000000..bff972cf9 --- /dev/null +++ b/descope/management/_outbound_application_base.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope.management.common import ( + AccessType, + PromptType, + URLParam, + url_params_to_dict, +) + + +class OutboundApplicationBase: + @staticmethod + def _compose_create_update_body( + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + id: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + body: dict[str, Any] = { + "name": name, + "id": id, + "description": description, + "logo": logo, + } + if client_secret: + body["clientSecret"] = client_secret + if client_id: + body["clientId"] = client_id + if discovery_url: + body["discoveryUrl"] = discovery_url + if authorization_url: + body["authorizationUrl"] = authorization_url + if authorization_url_params is not None: + body["authorizationUrlParams"] = url_params_to_dict(authorization_url_params) + if token_url: + body["tokenUrl"] = token_url + if token_url_params is not None: + body["tokenUrlParams"] = url_params_to_dict(token_url_params) + if revocation_url: + body["revocationUrl"] = revocation_url + if default_scopes is not None: + body["defaultScopes"] = default_scopes + if default_redirect_url: + body["defaultRedirectUrl"] = default_redirect_url + if callback_domain: + body["callbackDomain"] = callback_domain + if pkce is not None: + body["pkce"] = pkce + if access_type: + body["accessType"] = access_type.value + if prompt is not None: + body["prompt"] = [p.value for p in prompt] + return body diff --git a/descope/management/_tenant_base.py b/descope/management/_tenant_base.py new file mode 100644 index 000000000..c3cb2e4d8 --- /dev/null +++ b/descope/management/_tenant_base.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any, List, Optional + + +class TenantBase: + @staticmethod + def _compose_create_update_body( + name: str, + id: Optional[str], + self_provisioning_domains: List[str], + custom_attributes: Optional[dict] = None, + enforce_sso: Optional[bool] = False, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + disabled: Optional[bool] = False, + ) -> dict: + body: dict[str, Any] = { + "name": name, + "id": id, + "selfProvisioningDomains": self_provisioning_domains, + "enforceSSO": enforce_sso, + "disabled": disabled, + } + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + if enforce_sso_exclusions is not None: + body["enforceSSOExclusions"] = enforce_sso_exclusions + if federated_app_ids is not None: + body["federatedAppIds"] = federated_app_ids + return body diff --git a/descope/management/_user_base.py b/descope/management/_user_base.py new file mode 100644 index 000000000..5035e65a5 --- /dev/null +++ b/descope/management/_user_base.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope.common import DeliveryMethod, LoginOptions, get_method_string +from descope.management.common import ( + AssociatedTenant, + Sort, + associated_tenants_to_dict, + sort_to_dict, +) +from descope.management.user_pwd import UserPassword + + +class UserObj: + def __init__( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + password: Optional[UserPassword] = None, + seed: Optional[str] = None, + status: Optional[str] = None, + ): + self.login_id = login_id + self.email = email + self.phone = phone + self.display_name = display_name + self.given_name = given_name + self.middle_name = middle_name + self.family_name = family_name + self.role_names = role_names + self.user_tenants = user_tenants + self.picture = picture + self.custom_attributes = custom_attributes + self.verified_email = verified_email + self.verified_phone = verified_phone + self.additional_login_ids = additional_login_ids + self.sso_app_ids = sso_app_ids + self.password = password + self.seed = seed + self.status = status + + +class CreateUserObj: + def __init__( + self, + email: Optional[str] = None, + phone: Optional[str] = None, + name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + ): + self.email = email + self.phone = phone + self.name = name + self.given_name = given_name + self.middle_name = middle_name + self.family_name = family_name + + +class UserBase: + @staticmethod + def _compose_create_body( + login_id: str, + email: Optional[str], + phone: Optional[str], + display_name: Optional[str], + given_name: Optional[str], + middle_name: Optional[str], + family_name: Optional[str], + role_names: List[str], + user_tenants: List[AssociatedTenant], + invite: bool, + test: bool, + picture: Optional[str], + custom_attributes: Optional[dict], + verified_email: Optional[bool], + verified_phone: Optional[bool], + invite_url: Optional[str], + send_mail: Optional[bool], + send_sms: Optional[bool], + additional_login_ids: Optional[List[str]], + sso_app_ids: Optional[List[str]] = None, + template_id: str = "", + locale: Optional[str] = None, + ) -> dict: + body = UserBase._compose_update_body( + login_id=login_id, + email=email, + phone=phone, + display_name=display_name, + given_name=given_name, + middle_name=middle_name, + family_name=family_name, + role_names=role_names, + user_tenants=user_tenants, + test=test, + picture=picture, + custom_attributes=custom_attributes, + additional_login_ids=additional_login_ids, + sso_app_ids=sso_app_ids, + ) + body["invite"] = invite + if verified_email is not None: + body["verifiedEmail"] = verified_email + if verified_phone is not None: + body["verifiedPhone"] = verified_phone + if invite_url is not None: + body["inviteUrl"] = invite_url + if send_mail is not None: + body["sendMail"] = send_mail + if send_sms is not None: + body["sendSMS"] = send_sms + if template_id != "": + body["templateId"] = template_id + if locale is not None: + body["locale"] = locale + return body + + @staticmethod + def _compose_create_batch_body( + users: List[UserObj], + invite_url: Optional[str], + send_mail: Optional[bool], + send_sms: Optional[bool], + locale: Optional[str] = None, + ) -> dict: + usersBody = [] + for user in users: + role_names = [] if user.role_names is None else user.role_names + user_tenants = [] if user.user_tenants is None else user.user_tenants + sso_app_ids = [] if user.sso_app_ids is None else user.sso_app_ids + password = None if user.password is None else user.password.cleartext + hashed_password = None + if (user.password is not None) and (user.password.hashed is not None): + hashed_password = user.password.hashed.to_dict() + uBody = UserBase._compose_update_body( + login_id=user.login_id, + email=user.email, + phone=user.phone, + display_name=user.display_name, + given_name=user.given_name, + middle_name=user.middle_name, + family_name=user.family_name, + role_names=role_names, + user_tenants=user_tenants, + picture=user.picture, + custom_attributes=user.custom_attributes, + additional_login_ids=user.additional_login_ids, + verified_email=user.verified_email, + verified_phone=user.verified_phone, + test=False, + sso_app_ids=sso_app_ids, + password=password, + hashed_password=hashed_password, + seed=user.seed, + ) + if user.status is not None: + uBody["status"] = user.status + usersBody.append(uBody) + + body = {"users": usersBody, "invite": True} + if invite_url is not None: + body["inviteUrl"] = invite_url + if send_mail is not None: + body["sendMail"] = send_mail + if send_sms is not None: + body["sendSMS"] = send_sms + if locale is not None: + body["locale"] = locale + return body + + @staticmethod + def _compose_update_body( + login_id: str, + email: Optional[str], + phone: Optional[str], + display_name: Optional[str], + given_name: Optional[str], + middle_name: Optional[str], + family_name: Optional[str], + role_names: List[str], + user_tenants: List[AssociatedTenant], + test: bool, + picture: Optional[str], + custom_attributes: Optional[dict], + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + password: Optional[str] = None, + hashed_password: Optional[dict] = None, + seed: Optional[str] = None, + ) -> dict: + res = { + "loginId": login_id, + "email": email, + "phone": phone, + "displayName": display_name, + "roleNames": role_names, + "userTenants": associated_tenants_to_dict(user_tenants), + "test": test, + "picture": picture, + "customAttributes": custom_attributes, + "additionalLoginIds": additional_login_ids, + "ssoAppIDs": sso_app_ids, + } + if verified_email is not None: + res["verifiedEmail"] = verified_email + if given_name is not None: + res["givenName"] = given_name + if middle_name is not None: + res["middleName"] = middle_name + if family_name is not None: + res["familyName"] = family_name + if verified_phone is not None: + res["verifiedPhone"] = verified_phone + if password is not None: + res["password"] = password + if hashed_password is not None: + res["hashedPassword"] = hashed_password + if seed is not None: + res["seed"] = seed + return res + + @staticmethod + def _compose_patch_body( + login_id: str, + email: Optional[str], + phone: Optional[str], + display_name: Optional[str], + given_name: Optional[str], + middle_name: Optional[str], + family_name: Optional[str], + role_names: Optional[List[str]], + user_tenants: Optional[List[AssociatedTenant]], + picture: Optional[str], + custom_attributes: Optional[dict], + verified_email: Optional[bool], + verified_phone: Optional[bool], + sso_app_ids: Optional[List[str]], + status: Optional[str], + test: bool = False, + ) -> dict: + res: dict[str, Any] = { + "loginId": login_id, + } + if email is not None: + res["email"] = email + if phone is not None: + res["phone"] = phone + if display_name is not None: + res["displayName"] = display_name + if given_name is not None: + res["givenName"] = given_name + if middle_name is not None: + res["middleName"] = middle_name + if family_name is not None: + res["familyName"] = family_name + if role_names is not None: + res["roleNames"] = role_names + if user_tenants is not None: + res["userTenants"] = associated_tenants_to_dict(user_tenants) + if picture is not None: + res["picture"] = picture + if custom_attributes is not None: + res["customAttributes"] = custom_attributes + if verified_email is not None: + res["verifiedEmail"] = verified_email + if verified_phone is not None: + res["verifiedPhone"] = verified_phone + if sso_app_ids is not None: + res["ssoAppIds"] = sso_app_ids + if status is not None: + res["status"] = status + if test: + res["test"] = test + return res + + @staticmethod + def _compose_patch_batch_body( + users: List[UserObj], + test: bool = False, + ) -> dict: + users_body = [] + for user in users: + user_body = UserBase._compose_patch_body( + login_id=user.login_id, + email=user.email, + phone=user.phone, + display_name=user.display_name, + given_name=user.given_name, + middle_name=user.middle_name, + family_name=user.family_name, + role_names=user.role_names, + user_tenants=user.user_tenants, + picture=user.picture, + custom_attributes=user.custom_attributes, + verified_email=user.verified_email, + verified_phone=user.verified_phone, + sso_app_ids=user.sso_app_ids, + status=user.status, + test=test, + ) + users_body.append(user_body) + + return {"users": users_body} diff --git a/descope/management/access_key_async.py b/descope/management/access_key_async.py new file mode 100644 index 000000000..371170c15 --- /dev/null +++ b/descope/management/access_key_async.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import ( + AssociatedTenant, + MgmtV1, + associated_tenants_to_dict, +) + + +class AccessKeyAsync(AsyncHTTPBase): + """Async counterpart of AccessKey — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + expire_time: int = 0, + role_names: Optional[List[str]] = None, + key_tenants: Optional[List[AssociatedTenant]] = None, + user_id: Optional[str] = None, + custom_claims: Optional[dict] = None, + description: Optional[str] = None, + permitted_ips: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + ) -> dict: + """ + Create a new access key. + + Args: + name (str): Access key name. + expire_time (int): Access key expiration. Leave at 0 to make it indefinite. + role_names (List[str]): An optional list of the access key's roles without tenant association. These roles are + mutually exclusive with the `key_tenant` roles, which take precedence over them. + key_tenants (List[AssociatedTenant]): An optional list of the access key's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`, and take precedence over them. + user_id (str): Bind access key to this user id + If user_id is supplied, then authorizations will be ignored, and the access key will be bound to the user's authorization. + custom_claims (dict): Optional, map of claims and their values that will be present in the JWT. + description (str): an optional text the access key can hold. + permitted_ips: (List[str]): An optional list of IP addresses or CIDR ranges that are allowed to use the access key. + custom_attributes (dict): Optional, map of custom attributes and their values that will be associated with the access key. + + Return value (dict): + Return dict in the format + { + "key": {}, + "cleartext": {} + } + Containing the created access key information and its cleartext. The key cleartext will only be returned on creation. + Make sure to save it securely. + + Raise: + AuthException: raised if create operation fails + """ + role_names = [] if role_names is None else role_names + key_tenants = [] if key_tenants is None else key_tenants + + response = await self._http.post( + MgmtV1.access_key_create_path, + body=AccessKeyAsync._compose_create_body( + name, + expire_time, + role_names, + key_tenants, + user_id, + custom_claims, + description, + permitted_ips, + custom_attributes, + ), + ) + return response.json() + + async def load( + self, + id: str, + ) -> dict: + """ + Load an existing access key. + + Args: + id (str): The id of the access key to be loaded. + + Return value (dict): + Return dict in the format + {"key": {}} + Containing the loaded access key information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + uri=MgmtV1.access_key_load_path, + params={"id": id}, + ) + return response.json() + + async def search_all_access_keys( + self, + tenant_ids: Optional[List[str]] = None, + bound_user_id: Optional[str] = None, + creating_user: Optional[str] = None, + custom_attributes: Optional[dict] = None, + ) -> dict: + """ + Search all access keys. + + Args: + tenant_ids (List[str]): Optional list of tenant IDs to filter by + bound_user_id (str): Optional user ID of bounded user to filter by + creating_user (str): Optional user name of the creator to filter by + custom_attributes (dict): Optional dictionary of custom attributes to filter by + + Return value (dict): + Return dict in the format + {"keys": []} + "keys" contains a list of all of the found users and their information + + Raise: + AuthException: raised if search operation fails + """ + tenant_ids = [] if tenant_ids is None else tenant_ids + + response = await self._http.post( + MgmtV1.access_keys_search_path, + body={ + "tenantIds": tenant_ids, + "boundUserId": bound_user_id, + "creatingUser": creating_user, + "customAttributes": custom_attributes, + }, + ) + return response.json() + + async def update( + self, + id: str, + name: str, + description: Optional[str] = None, + custom_claims: Optional[dict] = None, + permitted_ips: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + ): + """ + Update an existing access key with the given various fields. IMPORTANT: id and name are mandatory fields. + + Args: + id (str): The id of the access key to update. + name (str): The updated access key name. + description (str): The description of the access key to update. If not provided, it will not be overriden. + custom_claims (dict): Optional dictionary of custom claims to update. If not provided, it will not be overridden. + permitted_ips (List[str]): Optional list of permitted IPs to update. If not provided, it will not be overridden. + custom_attributes (dict): Optional dictionary of custom attributes to update. If not provided, it will not be overridden. + + Raise: + AuthException: raised if update operation fails + """ + body: dict[str, str | List[str] | dict] = { + "id": id, + "name": name, + } + if description is not None: + body["description"] = description + if custom_claims is not None: + body["customClaims"] = custom_claims + if permitted_ips is not None: + body["permittedIps"] = permitted_ips + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + await self._http.post( + MgmtV1.access_key_update_path, + body=body, + ) + + async def deactivate( + self, + id: str, + ): + """ + Deactivate an existing access key. IMPORTANT: This deactivated key will not be usable from this stage. + It will, however, persist, and can be activated again if needed. + + Args: + id (str): The id of the access key to be deactivated. + + Raise: + AuthException: raised if deactivation operation fails + """ + await self._http.post( + MgmtV1.access_key_deactivate_path, + body={"id": id}, + ) + + async def activate( + self, + id: str, + ): + """ + Activate an existing access key. IMPORTANT: Only deactivated keys can be activated again, + and become usable once more. New access keys are active by default. + + Args: + id (str): The id of the access key to be activate. + + Raise: + AuthException: raised if activation operation fails + """ + await self._http.post( + MgmtV1.access_key_activate_path, + body={"id": id}, + ) + + async def delete( + self, + id: str, + ): + """ + Delete an existing access key. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The id of the access key to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.access_key_delete_path, + body={"id": id}, + ) + + @staticmethod + def _compose_create_body( + name: str, + expire_time: int, + role_names: List[str], + key_tenants: List[AssociatedTenant], + user_id: Optional[str] = None, + custom_claims: Optional[dict] = None, + description: Optional[str] = None, + permitted_ips: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + ) -> dict: + return { + "name": name, + "expireTime": expire_time, + "roleNames": role_names, + "keyTenants": associated_tenants_to_dict(key_tenants), + "userId": user_id, + "customClaims": custom_claims, + "description": description, + "permittedIps": permitted_ips, + "customAttributes": custom_attributes, + } diff --git a/descope/management/audit_async.py b/descope/management/audit_async.py new file mode 100644 index 000000000..7e1856a23 --- /dev/null +++ b/descope/management/audit_async.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class AuditAsync(AsyncHTTPBase): + """Async counterpart of Audit — all HTTP calls are coroutines.""" + + async def search( + self, + user_ids: Optional[List[str]] = None, + actions: Optional[List[str]] = None, + excluded_actions: Optional[List[str]] = None, + devices: Optional[List[str]] = None, + methods: Optional[List[str]] = None, + geos: Optional[List[str]] = None, + remote_addresses: Optional[List[str]] = None, + login_ids: Optional[List[str]] = None, + tenants: Optional[List[str]] = None, + no_tenants: bool = False, + text: Optional[str] = None, + from_ts: Optional[datetime] = None, + to_ts: Optional[datetime] = None, + ) -> dict: + """ + Search the audit trail up to last 30 days based on given parameters + + Args: + user_ids (List[str]): Optional list of user IDs to filter by + actions (List[str]): Optional list of actions to filter by + excluded_actions (List[str]): Optional list of actions to exclude + devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown" + methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password" + geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. + remote_addresses (List[str]): Optional list of remote addresses to filter by + login_ids (List[str]): Optional list of login IDs to filter by + tenants (List[str]): Optional list of tenants to filter by + no_tenants (bool): Should audits without any tenants always be included + text (str): Free text search across all fields + from_ts (datetime): Retrieve records newer than given time but not older than 30 days + to_ts (datetime): Retrieve records older than given time + + Return value (dict): + Return dict in the format + { + "audits": [ + { + "projectId":"", + "userId": "", + "action": "", + "occurred": 0 (unix-time-milli), + "device": "", + "method": "", + "geo": "", + "remoteAddress": "", + "externalIds": [""], + "tenants": [""], + "data": { + "field1": "field1-value", + "more-details": "in-console-examples" + } + } + ] + } + Raise: + AuthException: raised if search operation fails + """ + body: dict[str, Any] = {"noTenants": no_tenants} + if user_ids is not None: + body["userIds"] = user_ids + if actions is not None: + body["actions"] = actions + if excluded_actions is not None: + body["excludedActions"] = excluded_actions + if devices is not None: + body["devices"] = devices + if methods is not None: + body["methods"] = methods + if geos is not None: + body["geos"] = geos + if remote_addresses is not None: + body["remoteAddresses"] = remote_addresses + if login_ids is not None: + body["externalIds"] = login_ids + if tenants is not None: + body["tenants"] = tenants + if text is not None: + body["text"] = text + if from_ts is not None: + body["from"] = int(from_ts.timestamp() * 1000) + if to_ts is not None: + body["to"] = int(to_ts.timestamp() * 1000) + + response = await self._http.post(MgmtV1.audit_search, body=body) + return {"audits": list(map(AuditAsync._convert_audit_record, response.json()["audits"]))} + + async def create_event( + self, + action: str, + type: str, + actor_id: str, + tenant_id: str, + user_id: Optional[str] = None, + data: Optional[dict] = None, + ): + """ + Create audit event based on given parameters + + Args: + action (str): Audit action + type (str): Audit type (info/warn/error) + actor_id (str): Audit actor id + tenant_id (str): Audit tenant id + user_id (str): Optional, Audit user id + data (dict): Optional, Audit data + + Raise: + AuthException: raised if search operation fails + """ + body: dict[str, Any] = { + "action": action, + "type": type, + "actorId": actor_id, + "tenantId": tenant_id, + } + if user_id is not None: + body["userId"] = user_id + if data is not None: + body["data"] = data + + await self._http.post(MgmtV1.audit_create_event, body=body) + + @staticmethod + def _convert_audit_record(a: dict) -> dict: + return { + "projectId": a.get("projectId", ""), + "userId": a.get("userId", ""), + "action": a.get("action", ""), + "occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000), + "device": a.get("device", ""), + "method": a.get("method", ""), + "geo": a.get("geo", ""), + "remoteAddress": a.get("remoteAddress", ""), + "loginIds": a.get("externalIds", []), + "tenants": a.get("tenants", []), + "data": a.get("data", {}), + } diff --git a/descope/management/authz_async.py b/descope/management/authz_async.py new file mode 100644 index 000000000..72f00fb7f --- /dev/null +++ b/descope/management/authz_async.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class AuthzAsync(AsyncHTTPBase): + """Async counterpart of Authz — all HTTP calls are coroutines.""" + + def __init__(self, http_client, fga_cache_url: Optional[str] = None): + super().__init__(http_client) + self._fga_cache_url = fga_cache_url + + async def save_schema(self, schema: dict, upgrade: bool = False): + """ + Create or update the ReBAC schema. + In case of update, will update only given namespaces and will not delete namespaces unless upgrade flag is true. + Args: + schema (dict): the schema dict with format + { + "name": "name-of-schema", + "namespaces": [ + { + "name": "name-of-namespace", + "relationDefinitions": [ + { + "name": "name-of-relation-definition", + "complexDefinition": { + "nType": "one of child|union|intersect|sub", + "children": "optional list of node children - same format as complexDefinition", + "expression": { + "neType": "one of self|targetSet|relationLeft|relationRight", + "relationDefinition": "name of relation definition for relationLeft and relationRight", + "relationDefinitionNamespace": "the namespace for the rd above", + "targetRelationDefinition": "relation definition for targetSet and relationLeft/right", + "targetRelationDefinitionNamespace": "the namespace for above" + } + } + } + ] + } + ] + } + Schema name can be used for projects to track versioning. + Raise: + AuthException: raised if saving fails + """ + await self._http.post( + MgmtV1.authz_schema_save, + body={"schema": schema, "upgrade": upgrade}, + ) + + async def delete_schema(self): + """ + Delete the schema for the project which will also delete all relations. + Raise: + AuthException: raised if delete schema fails + """ + await self._http.post( + MgmtV1.authz_schema_delete, + ) + + async def load_schema(self) -> dict: + """ + Load the schema for the project + Return value (dict): + Return dict in the format of schema as above (see save_schema) + Raise: + AuthException: raised if load schema fails + """ + response = await self._http.post( + MgmtV1.authz_schema_load, + ) + return response.json()["schema"] + + async def save_namespace(self, namespace: dict, old_name: str = "", schema_name: str = ""): + """ + Create or update the given namespace + Will not delete relation definitions not mentioned in the namespace. + Args: + namespace (dict): namespace in the format as specified above (see save_schema) + old_name (str): is used if we are changing the namespace name + schema_name (str): is optional and can be used to track the current schema version. + Raise: + AuthException: raised if save namespace fails + """ + body: dict[str, Any] = {"namespace": namespace} + if old_name != "": + body["oldName"] = old_name + if schema_name != "": + body["schemaName"] = schema_name + await self._http.post( + MgmtV1.authz_ns_save, + body=body, + ) + + async def delete_namespace(self, name: str, schema_name: str = ""): + """ + delete_namespace will also delete the relevant relations. + Args: + name (str): namespace name to delete + schema_name (str): is optional and can be used to track the current schema version. + Raise: + AuthException: raised if delete namespace fails + """ + body: dict[str, Any] = {"name": name} + if schema_name != "": + body["schemaName"] = schema_name + await self._http.post( + MgmtV1.authz_ns_delete, + body=body, + ) + + async def save_relation_definition( + self, + relation_definition: dict, + namespace: str, + old_name: str = "", + schema_name: str = "", + ): + """ + Create or update the given relation definition + Will not delete relation definitions not mentioned in the namespace. + Args: + relation_definition (dict): relation definition in the format as specified above (see save_schema) + namespace (str): the namespace for the relation definition + old_name (str): is used if we are changing the relation definition name + schema_name (str): is optional and can be used to track the current schema version. + Raise: + AuthException: raised if save relation definition fails + """ + body: dict[str, Any] = { + "relationDefinition": relation_definition, + "namespace": namespace, + } + if old_name != "": + body["oldName"] = old_name + if schema_name != "": + body["schemaName"] = schema_name + await self._http.post( + MgmtV1.authz_rd_save, + body=body, + ) + + async def delete_relation_definition(self, name: str, namespace: str, schema_name: str = ""): + """ + delete_relation_definition will also delete the relevant relations. + Args: + name (str): relation definition name to delete + namespace (str): the namespace for the relation definition + schema_name (str): is optional and can be used to track the current schema version. + Raise: + AuthException: raised if delete namespace fails + """ + body: dict[str, Any] = {"name": name, "namespace": namespace} + if schema_name != "": + body["schemaName"] = schema_name + await self._http.post( + MgmtV1.authz_rd_delete, + body=body, + ) + + async def create_relations( + self, + relations: List[dict], + ): + """ + Create the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the following format: + { + "resource": "id of the resource that has the relation", + "relationDefinition": "the relation definition for the relation", + "namespace": "namespace for the relation definition", + "target": "the target that has the relation - usually users or other resources", + "targetSetResource": "if the target is a group that has another relation", + "targetSetRelationDefinition": "the relation definition for the targetSet group", + "targetSetRelationDefinitionNamespace": "the namespace for the relation definition for the targetSet group", + "query": { + "tenants": ["t1", "t2"], + "roles": ["r1", "r2"], + "text": "full-text-search", + "statuses": ["enabled|disabled|invited|expired"], + "ssoOnly": True|False, + "withTestUser": True|False, + "customAttributes": { + "key": "value", + ... + } + } + } + Each relation should have exactly one of: target, targetSet, query + Regarding query above, it should be specified if the target is a set of users that matches the query - all fields are optional + Raise: + AuthException: raised if create relations fails + """ + await self._http.post( + MgmtV1.authz_re_create, + body={"relations": relations}, + ) + + async def delete_relations( + self, + relations: List[dict], + ): + """ + Delete the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) + Raise: + AuthException: raised if delete relations fails + """ + await self._http.post( + MgmtV1.authz_re_delete, + body={"relations": relations}, + ) + + async def delete_relations_for_resources( + self, + resources: List[str], + ): + """ + Delete all relations to the given resources + Args: + resources (List[str]): the list of resources to delete any relations for + Raise: + AuthException: raised if delete relations for resources fails + """ + await self._http.post( + MgmtV1.authz_re_delete_resources, + body={"resources": resources}, + ) + + async def has_relations( + self, + relation_queries: List[dict], + ) -> List[dict]: + """ + Queries the given relations to see if they exist returning true if they do + Args: + relation_queries (List[dict]): List of queries each in the format of: + { + "resource": "resource for the relation query", + "relationDefinition": "the relation definition for the relation query", + "namespace": "namespace for the relation definition", + "target": "the target that has the relation - usually users or other resources" + } + + Return value (List[dict]): + Return List in the format + [ + { + "resource": "resource for the relation query", + "relationDefinition": "the relation definition for the relation query", + "namespace": "namespace for the relation definition", + "target": "the target that has the relation - usually users or other resources", + "hasRelation": True|False + } + ] + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_has_relations, + body={"relationQueries": relation_queries}, + ) + return response.json()["relationQueries"] + + async def who_can_access(self, resource: str, relation_definition: str, namespace: str) -> List[dict]: + """ + Finds the list of targets (usually users) who can access the given resource with the given RD + Args: + resource (str): the resource we are checking + relation_definition (str): the RD we are checking + namespace (str): the namespace for the RD + + Return value (List[str]): list of targets (user IDs usually that have the access) + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_who, + body={ + "resource": resource, + "relationDefinition": relation_definition, + "namespace": namespace, + }, + base_url=self._fga_cache_url, + ) + return response.json()["targets"] + + async def resource_relations(self, resource: str) -> List[dict]: + """ + Returns the list of all defined relations (not recursive) on the given resource. + Args: + resource (str): the resource we are listing relations for + + Return value (List[dict]): + Return List of relations each in the format of a relation as documented in create_relations + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_resource, + body={"resource": resource}, + ) + return response.json()["relations"] + + async def targets_relations(self, targets: List[str]) -> List[dict]: + """ + Returns the list of all defined relations (not recursive) for the given targets. + Args: + targets (List[str]): the list of targets we are returning the relations for + + Return value (List[dict]): + Return List of relations each in the format of a relation as documented in create_relations + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_targets, + body={"targets": targets}, + ) + return response.json()["relations"] + + async def what_can_target_access(self, target: str) -> List[dict]: + """ + Returns the list of all relations for the given target including derived relations from the schema tree. + Args: + target (str): the target we are returning the relations for + + Return value (List[dict]): + Return List of relations each in the format of a relation as documented in create_relations + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_target_all, + body={"target": target}, + base_url=self._fga_cache_url, + ) + return response.json()["relations"] + + async def what_can_target_access_with_relation(self, target: str, relation_definition: str, namespace: str) -> List[dict]: + """ + Returns the list of all resources that the target has the given relation to including all derived relations + Args: + target (str): the target we are returning the relations for + relation_definition (str): the RD we are checking + namespace (str): the namespace for the RD + + Return value (List[dict]): + Return List of relations each in the format of a relation as documented in create_relations + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_re_target_with_relation, + body={ + "target": target, + "relationDefinition": relation_definition, + "namespace": namespace, + }, + ) + return response.json()["relations"] + + async def get_modified(self, since: Optional[datetime] = None) -> dict: + """ + Get all targets and resources changed since the given date. + Args: + since (datetime): only return changes from this given datetime + + Return value (dict): + Dict including "resources" list of strings, "targets" list of strings and "schemaChanged" bool + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.authz_get_modified, + body={"since": (int(since.replace(tzinfo=timezone.utc).timestamp() * 1000) if since else 0)}, + ) + return response.json()["relations"] diff --git a/descope/management/descoper_async.py b/descope/management/descoper_async.py new file mode 100644 index 000000000..e0ba8ef1e --- /dev/null +++ b/descope/management/descoper_async.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import ( + DescoperAttributes, + DescoperCreate, + DescoperRBAC, + MgmtV1, + descopers_to_dict, +) + + +class DescoperAsync(AsyncHTTPBase): + """Async counterpart of Descoper — all HTTP calls are coroutines.""" + + async def create( + self, + descopers: List[DescoperCreate], + ) -> dict: + """ + Create new Descopers. + + Args: + descopers (List[DescoperCreate]): List of Descopers to create. + Note that tags are referred to by name, without the company ID prefix. + + Return value (dict): + Return dict in the format + { + "descopers": [...], + "total": + } + + Raise: + AuthException: raised if create operation fails + """ + if not descopers: + raise ValueError("descopers list cannot be empty") + + response = await self._http.put( + MgmtV1.descoper_create_path, + body={"descopers": descopers_to_dict(descopers)}, + ) + return response.json() + + async def update( + self, + id: str, + attributes: Optional[DescoperAttributes] = None, + rbac: Optional[DescoperRBAC] = None, + ) -> dict: + """ + Update an existing Descoper's RBAC and/or Attributes. + + IMPORTANT: All parameter *fields*, if set, will override whatever values are currently set + in the existing Descoper. Use carefully. + + Args: + id (str): The id of the Descoper to update. + attributes (DescoperAttributes): Optional attributes to update. + rbac (DescoperRBAC): Optional RBAC configuration to update. + + Return value (dict): + Return dict in the format + {"descoper": {...}} + Containing the updated Descoper information. + + Raise: + AuthException: raised if update operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + body: dict[str, Any] = {"id": id} + if attributes is not None: + body["attributes"] = attributes.to_dict() + if rbac is not None: + body["rbac"] = rbac.to_dict() + + response = await self._http.patch( + MgmtV1.descoper_update_path, + body=body, + ) + return response.json() + + async def load( + self, + id: str, + ) -> dict: + """ + Load an existing Descoper by ID. + + Args: + id (str): The id of the Descoper to load. + + Return value (dict): + Return dict in the format + {"descoper": {...}} + Containing the loaded Descoper information. + + Raise: + AuthException: raised if load operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + response = await self._http.get( + uri=MgmtV1.descoper_load_path, + params={"id": id}, + ) + return response.json() + + async def delete( + self, + id: str, + ): + """ + Delete an existing Descoper. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The id of the Descoper to delete. + + Raise: + AuthException: raised if delete operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + await self._http.delete( + uri=MgmtV1.descoper_delete_path, + params={"id": id}, + ) + + async def list( + self, + ) -> dict: + """ + List all Descopers. + + Return value (dict): + Return dict in the format + { + "descopers": [...], + "total": + } + Containing all Descopers and the total count. + + Raise: + AuthException: raised if list operation fails + """ + response = await self._http.post( + MgmtV1.descoper_list_path, + body={}, + ) + return response.json() diff --git a/descope/management/fga_async.py b/descope/management/fga_async.py new file mode 100644 index 000000000..5ab3b4ec6 --- /dev/null +++ b/descope/management/fga_async.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class FGAAsync(AsyncHTTPBase): + """Async counterpart of FGA — all HTTP calls are coroutines.""" + + def __init__(self, http_client, fga_cache_url: Optional[str] = None): + super().__init__(http_client) + self._fga_cache_url = fga_cache_url + + async def save_schema(self, schema: str): + """ + Create or update an FGA schema. + Args: + schema (str): the schema in the AuthZ 1.0 DSL + model AuthZ 1.0 + + type user + + type org + relation member: user + relation parent: org + + type folder + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + + type doc + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + Raise: + AuthException: raised if saving fails + """ + await self._http.post( + MgmtV1.fga_save_schema, + body={"dsl": schema}, + base_url=self._fga_cache_url, + ) + + async def create_relations( + self, + relations: List[dict], + ): + """ + Create the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the following format: + { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace) - can also be group#member for target sets" + } + Raise: + AuthException: raised if create relations fails + """ + await self._http.post( + MgmtV1.fga_create_relations, + body={"tuples": relations}, + base_url=self._fga_cache_url, + ) + + async def delete_relations( + self, + relations: List[dict], + ): + """ + Delete the given relations based on the existing schema + Args: + relations (List[dict]): the relations to create. Each in the format as specified above for (create_relations) + Raise: + AuthException: raised if delete relations fails + """ + await self._http.post( + MgmtV1.fga_delete_relations, + body={"tuples": relations}, + base_url=self._fga_cache_url, + ) + + async def check( + self, + relations: List[dict], + ) -> List[dict]: + """ + Queries the given relations to see if they exist returning true if they do + Args: + relations (List[dict]): List of relation queries each in the format of: + { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace)" + } + + Return value (List[dict]): + Return List in the format + [ + { + "allowed": True|False + "relation": { + "resource": "id of the resource that has the relation", + "resourceType": "the type of the resource (namespace)", + "relation": "the relation definition for the relation", + "target": "the target that has the relation - usually users or other resources", + "targetType": "the type of the target (namespace)" + } + } + ] + Raise: + AuthException: raised if query fails + """ + response = await self._http.post( + MgmtV1.fga_check, + body={"tuples": relations}, + base_url=self._fga_cache_url, + ) + return list( + map( + lambda tuple: {"relation": tuple["tuple"], "allowed": tuple["allowed"]}, + response.json()["tuples"], + ) + ) + + async def load_resources_details(self, resource_identifiers: List[dict]) -> List[dict]: + """ + Load details for the given resource identifiers. + Args: + resource_identifiers (List[dict]): list of dicts each containing 'resourceId' and 'resourceType'. + Returns: + List[dict]: list of resources details as returned by the server. + """ + response = await self._http.post( + MgmtV1.fga_resources_load, + body={"resourceIdentifiers": resource_identifiers}, + ) + return response.json().get("resourcesDetails", []) + + async def save_resources_details(self, resources_details: List[dict]) -> None: + """ + Save details for the given resources. + Args: + resources_details (List[dict]): list of dicts each containing 'resourceId' and 'resourceType' plus optionally containing metadata fields such as 'displayName'. + """ + await self._http.post( + MgmtV1.fga_resources_save, + body={"resourcesDetails": resources_details}, + ) diff --git a/descope/management/flow_async.py b/descope/management/flow_async.py new file mode 100644 index 000000000..826b562f9 --- /dev/null +++ b/descope/management/flow_async.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from typing import List, Optional, Union + +from descope._http_base import AsyncHTTPBase +from descope.management.common import FlowRunOptions, MgmtV1 + + +class FlowAsync(AsyncHTTPBase): + """Async counterpart of Flow — all HTTP calls are coroutines.""" + + async def list_flows( + self, + ) -> dict: + """ + List all project flows + + Return value (dict): + Return dict in the format + { "flows": [{"id": "", "name": "", "description": "", "disabled": False}], total: number} + + Raise: + AuthException: raised if list operation fails + """ + response = await self._http.post(MgmtV1.flow_list_path) + return response.json() + + async def delete_flows( + self, + flow_ids: List[str], + ) -> dict: + """ + Delete flows by the given ids + + Args: + flow_ids (List[str]): list of flow IDs to delete. + + Raise: + AuthException: raised if delete operation fails + """ + response = await self._http.post( + MgmtV1.flow_delete_path, + body={ + "ids": flow_ids, + }, + ) + return response.json() + + async def export_flow( + self, + flow_id: str, + ) -> dict: + """ + Export the given flow id flow and screens. + + Args: + flow_id (str): the flow id to export. + + Return value (dict): + Return dict in the format + { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } + + Raise: + AuthException: raised if export operation fails + """ + response = await self._http.post( + MgmtV1.flow_export_path, + body={ + "flowId": flow_id, + }, + ) + return response.json() + + async def import_flow( + self, + flow_id: str, + flow: dict, + screens: List[dict], + ) -> dict: + """ + Import the given flow and screens to the flow id. + Imoprtant: This will override the current project flow by the given id, treat with caution. + + Args: + flow_id (str): the flow id to import to. + flow (dict): the flow to import. dict in the format + { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}} + screens (List[dict]): the flow screens to import. list of dictss in the format: + { "id": "", "inputs": [], "interactions": [] } + + Return value (dict): + Return dict in the format + { "flow": {"id": "", "name": "", "description": "", "disabled": False, "etag": "", "dsl": {}}, screens: [{ "id": "", "inputs": [], "interactions": [] }] } + + Raise: + AuthException: raised if import operation fails + """ + response = await self._http.post( + MgmtV1.flow_import_path, + body={ + "flowId": flow_id, + "flow": flow, + "screens": screens, + }, + ) + return response.json() + + async def export_theme( + self, + ) -> dict: + """ + Export the current project theme. + + Return value (dict): + Return dict in the format + {"id": "", "cssTemplate": {} } + + Raise: + AuthException: raised if export operation fails + """ + response = await self._http.post( + MgmtV1.theme_export_path, + body={}, + ) + return response.json() + + async def import_theme( + self, + theme: dict, + ) -> dict: + """ + Import the given theme as the current project theme. + Imoprtant: This will override the current project theme, treat with caution. + + Args: + theme (Theme): the theme to import. dict in the format + {"id": "", "cssTemplate": {} } + + Return value (dict): + Return dict in the format + {"id": "", "cssTemplate": {} } + + Raise: + AuthException: raised if import operation fails + """ + response = await self._http.post( + MgmtV1.theme_import_path, + body={ + "theme": theme, + }, + ) + return response.json() + + async def run_flow( + self, + flow_id: str, + options: Optional[Union[FlowRunOptions, dict]] = None, + ) -> dict: + """ + Run a flow with the given flow id and options. + + Args: + flow_id (str): the flow id to run. + options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: + - input: optional input data to pass to the flow. + - preview: optional flag to run the flow in preview mode. + - tenant: optional tenant ID to run the flow for. + + Return value (dict): + Return dict with the flow execution result. + + Raise: + AuthException: raised if run operation fails + """ + body: dict = {"flowId": flow_id} + + if options is not None: + if isinstance(options, dict): + options = FlowRunOptions.from_dict(options) + if options is not None: + body.update(options.to_dict()) + + response = await self._http.post( + MgmtV1.flow_run_path, + body=body, + ) + return response.json() + + async def run_flow_async( + self, + flow_id: str, + options: Optional[Union[FlowRunOptions, dict]] = None, + ) -> dict: + """ + Run a flow asynchronously with the given flow id and options. + + Args: + flow_id (str): the flow id to run. + options (Optional[Union[FlowRunOptions, dict]]): optional flow run options containing: + - input: optional input data to pass to the flow. + - preview: optional flag to run the flow in preview mode. + - tenant: optional tenant ID to run the flow for. + + Return value (dict): + Return dict with the async flow execution result. + use the get_flow_async_result() method with this result's executionId + to get the actual flow's result. + + Raise: + AuthException: raised if run operation fails + """ + body: dict = {"flowId": flow_id} + + if options is not None: + if isinstance(options, dict): + options = FlowRunOptions.from_dict(options) + if options is not None: + body.update(options.to_dict()) + + response = await self._http.post( + MgmtV1.flow_async_run_path, + body=body, + ) + return response.json() + + async def get_flow_async_result( + self, + execution_id: str, + ) -> dict: + """ + Get the result of an async flow execution. + + Args: + execution_id (str): the execution id returned from run_flow_async. + + Return value (dict): + Return dict with the async flow execution result. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.flow_async_result_path, + body={"executionId": execution_id}, + ) + return response.json() diff --git a/descope/management/group_async.py b/descope/management/group_async.py new file mode 100644 index 000000000..ee8492c58 --- /dev/null +++ b/descope/management/group_async.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class GroupAsync(AsyncHTTPBase): + """Async counterpart of Group — all HTTP calls are coroutines.""" + + async def load_all_groups( + self, + tenant_id: str, + ) -> dict: + """ + Load all groups for a specific tenant id. + + Args: + tenant_id (str): Tenant ID to load groups from. + + Return value (dict): + Return dict in the format + [ + { + "id": , + "display": , + "members":[ + { + "loginId": , + "userId": , + "display": + } + ] + } + ] + Containing the loaded groups information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.post( + MgmtV1.group_load_all_path, + body={ + "tenantId": tenant_id, + }, + ) + return response.json() + + async def load_all_groups_for_members( + self, + tenant_id: str, + user_ids: Optional[List[str]] = None, + login_ids: Optional[List[str]] = None, + ) -> dict: + """ + Load all groups for the provided user IDs or login IDs. + + Args: + tenant_id (str): Tenant ID to load groups from. + user_ids (List[str]): Optional List of user IDs, with the format of "U2J5ES9S8TkvCgOvcrkpzUgVTEBM" (example), which can be found on the user's JWT. + login_ids (List[str]): Optional List of login IDs, how the users identify when logging in. + + Return value (dict): + Return dict in the format + [ + { + "id": , + "display": , + "members":[ + { + "loginId": , + "userId": , + "display": + } + ] + } + ] + Containing the loaded groups information. + + Raise: + AuthException: raised if load operation fails + """ + user_ids = [] if user_ids is None else user_ids + login_ids = [] if login_ids is None else login_ids + + response = await self._http.post( + MgmtV1.group_load_all_for_member_path, + body={ + "tenantId": tenant_id, + "loginIds": login_ids, + "userIds": user_ids, + }, + ) + return response.json() + + async def load_all_group_members( + self, + tenant_id: str, + group_id: str, + ) -> dict: + """ + Load all members of the provided group id. + + Args: + tenant_id (str): Tenant ID to load groups from. + group_id (str): Group ID to load members for. + + Return value (dict): + Return dict in the format + [ + { + "id": , + "display": , + "members":[ + { + "loginId": , + "userId": , + "display": + } + ] + } + ] + Containing the loaded groups information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.post( + MgmtV1.group_load_all_group_members_path, + body={ + "tenantId": tenant_id, + "groupId": group_id, + }, + ) + return response.json() diff --git a/descope/management/jwt_async.py b/descope/management/jwt_async.py new file mode 100644 index 000000000..2f3fafe57 --- /dev/null +++ b/descope/management/jwt_async.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +from typing import Optional + +from descope._http_base import AsyncHTTPBase +from descope.auth import Auth +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.jwt_common import generate_jwt_response +from descope.management.common import ( + MgmtLoginOptions, + MgmtSignUpOptions, + MgmtUserRequest, + MgmtV1, + is_jwt_required, +) + + +class JWTAsync(AsyncHTTPBase): + """Async counterpart of JWT — all HTTP calls are coroutines.""" + + _auth: Auth + + def __init__(self, http_client, auth: Auth): + super().__init__(http_client) + self._auth = auth + + async def update_jwt(self, jwt: str, custom_claims: dict, refresh_duration: int = 0) -> str: + """ + Given a valid JWT, update it with custom claims, and update its authz claims as well + + Args: + token (str): valid jwt. + custom_claims (dict): Custom claims to add to JWT, system claims will be filtered out + refresh_duration (int): duration in seconds for which the new JWT will be valid + + Return value (str): the newly updated JWT + + Raise: + AuthException: raised if update failed + """ + if not jwt: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") + response = await self._http.post( + MgmtV1.update_jwt_path, + body={ + "jwt": jwt, + "customClaims": custom_claims, + "refreshDuration": refresh_duration, + }, + params=None, + ) + return response.json().get("jwt", "") + + async def impersonate( + self, + impersonator_id: str, + login_id: str, + validate_consent: bool, + custom_claims: Optional[dict] = None, + tenant_id: Optional[str] = None, + refresh_duration: Optional[int] = None, + stepup: Optional[bool] = None, + ) -> str: + """ + Impersonate to another user + + Args: + impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. + login_id (str): login id of the user whom to which to impersonate to. + validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. + customClaims dict: Custom claims to add to JWT + tenant_id (str): tenant id to set on DCT claim. + refresh_duration (int): duration in seconds for which the new JWT will be valid + stepup (bool): Whether to generate a stepup token for the impersonated user. + + Return value (str): A JWT of the impersonated user + + Raise: + AuthException: raised if update failed + """ + if not impersonator_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "impersonator_id cannot be empty") + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + response = await self._http.post( + MgmtV1.impersonate_path, + body={ + "loginId": login_id, + "impersonatorId": impersonator_id, + "validateConsent": validate_consent, + "customClaims": custom_claims, + "selectedTenant": tenant_id, + "refreshDuration": refresh_duration, + "stepup": stepup, + }, + params=None, + ) + return response.json().get("jwt", "") + + async def stop_impersonation( + self, + jwt: str, + custom_claims: Optional[dict] = None, + tenant_id: Optional[str] = None, + refresh_duration: Optional[int] = None, + ) -> str: + """ + Stop impersonation and return to the original user + Args: + jwt (str): The impersonation jwt to stop. + customClaims dict: Custom claims to add to JWT + tenant_id (str): tenant id to set on DCT claim. + refresh_duration (int): duration in seconds for which the new JWT will be valid + + Return value (str): A JWT of the actor + + Raise: + AuthException: raised if update failed + """ + if not jwt or jwt == "": + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") + + response = await self._http.post( + MgmtV1.stop_impersonation_path, + body={ + "jwt": jwt, + "customClaims": custom_claims, + "selectedTenant": tenant_id, + "refreshDuration": refresh_duration, + }, + params=None, + ) + return response.json().get("jwt", "") + + async def sign_in(self, login_id: str, login_options: Optional[MgmtLoginOptions] = None) -> dict: + """ + Generate a JWT for a user, simulating a signin request. + + Args: + login_id (str): login id of the user. + login_options (MgmtLoginOptions): options for the login request. + """ + + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if login_options is None: + login_options = MgmtLoginOptions() + + if is_jwt_required(login_options) and not login_options.jwt: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") + + response = await self._http.post( + MgmtV1.mgmt_sign_in_path, + body={ + "loginId": login_id, + "stepup": login_options.stepup, + "mfa": login_options.mfa, + "revokeOtherSessions": login_options.revoke_other_sessions, + "customClaims": login_options.custom_claims, + "jwt": login_options.jwt, + "refreshDuration": login_options.refresh_duration, + }, + params=None, + ) + resp = response.json() + jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) + return jwt_response + + async def sign_up( + self, + login_id: str, + user: Optional[MgmtUserRequest] = None, + signup_options: Optional[MgmtSignUpOptions] = None, + ) -> dict: + """ + Generate a JWT for a user, simulating a signup request. + + Args: + login_id (str): login id of the user. + user (MgmtUserRequest): user details. + signup_options (MgmtSignUpOptions): signup options. + """ + + return await self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_path, user, signup_options) + + async def sign_up_or_in( + self, + login_id: str, + user: Optional[MgmtUserRequest] = None, + signup_options: Optional[MgmtSignUpOptions] = None, + ) -> dict: + """ + Generate a JWT for a user, simulating a signup or in request. + + Args: + login_id (str): login id of the user. + user (MgmtUserRequest): user details. + signup_options (MgmtSignUpOptions): signup options. + """ + return await self._sign_up_internal(login_id, MgmtV1.mgmt_sign_up_or_in_path, user, signup_options) + + async def _sign_up_internal( + self, + login_id: str, + endpoint: str, + user: Optional[MgmtUserRequest] = None, + signup_options: Optional[MgmtSignUpOptions] = None, + ) -> dict: + if user is None: + user = MgmtUserRequest() + + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + if signup_options is None: + signup_options = MgmtSignUpOptions() + + response = await self._http.post( + endpoint, + body={ + "loginId": login_id, + "user": user.to_dict(), + "emailVerified": user.email_verified, + "phoneVerified": user.phone_verified, + "ssoAppId": user.sso_app_id, + "customClaims": signup_options.custom_claims, + "refreshDuration": signup_options.refresh_duration, + }, + params=None, + ) + resp = response.json() + jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) + return jwt_response + + async def anonymous( + self, + custom_claims: Optional[dict] = None, + tenant_id: Optional[str] = None, + refresh_duration: Optional[int] = None, + ) -> dict: + """ + Generate a JWT for an anonymous user. + + Args: + custom_claims dict: Custom claims to add to JWT + tenant_id (str): tenant id to set on DCT claim. + """ + + response = await self._http.post( + MgmtV1.anonymous_path, + body={ + "customClaims": custom_claims, + "selectedTenant": tenant_id, + "refreshDuration": refresh_duration, + }, + params=None, + ) + resp = response.json() + jwt_response = generate_jwt_response(resp, None, None, self._auth.validate_token) + del jwt_response["firstSeen"] + del jwt_response["user"] + return jwt_response diff --git a/descope/management/license_async.py b/descope/management/license_async.py new file mode 100644 index 000000000..388315257 --- /dev/null +++ b/descope/management/license_async.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class LicenseAsync(AsyncHTTPBase): + """Async counterpart of License — all HTTP calls are coroutines.""" + + async def get(self) -> dict: + """ + Fetch the rate limit tier for the project's company license. + + Returns a dict with a ``rateLimitTier`` field whose value is one of + ``tier1`` (free), ``tier2`` (pro), ``tier3`` (growth), or ``tier4`` + (enterprise). The SDK sends this value in the ``x-descope-license`` + header on every management request so Cloudflare can apply the right + rate limit bucket. + """ + response = await self._http.get(MgmtV1.license_get_path) + return response.json() diff --git a/descope/management/management_key_async.py b/descope/management/management_key_async.py new file mode 100644 index 000000000..cd69ffa46 --- /dev/null +++ b/descope/management/management_key_async.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtKeyReBac, MgmtKeyStatus, MgmtV1 + + +class ManagementKeyAsync(AsyncHTTPBase): + """Async counterpart of ManagementKey — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + rebac: MgmtKeyReBac, + description: Optional[str] = None, + expires_in: int = 0, + permitted_ips: Optional[List[str]] = None, + ) -> dict: + """ + Create a new management key. + + Args: + name (str): The name of the management key. + rebac (MgmtKeyReBac): RBAC configuration for the key. + description (str): Optional description for the management key. + expires_in (int): Expiration time in seconds (0 for no expiration). + permitted_ips (List[str]): Optional list of IP addresses or CIDR ranges that are allowed to use this key. + + Return value (dict): + Return dict in the format + { + "key": {...}, + "cleartext": "..." + } + + Raise: + AuthException: raised if create operation fails + """ + if not name: + raise ValueError("name cannot be empty") + if rebac is None: + raise ValueError("rebac cannot be empty") + + body: dict[str, Any] = { + "name": name, + "description": description, + "expiresIn": expires_in, + "permittedIps": permitted_ips if permitted_ips is not None else [], + "reBac": rebac.to_dict(), + } + + response = await self._http.put( + MgmtV1.mgmt_key_create_path, + body=body, + ) + return response.json() + + async def update( + self, + id: str, + name: str, + description: str, + permitted_ips: List[str], + status: MgmtKeyStatus, + ) -> dict: + """ + Update an existing management key. + + IMPORTANT: All parameters will override whatever values are currently set + in the existing management key. Use carefully. + + Args: + id (str): The id of the management key to update. + name (str): The updated name. + description (str): Updated description. + permitted_ips (List[str]): Updated list of IP addresses or CIDR ranges. + status (MgmtKeyStatus): Updated status. + + Return value (dict): + Return dict in the format + {"key": {...}} + Containing the updated management key information. + + Raise: + AuthException: raised if update operation fails + """ + if not id: + raise ValueError("id cannot be empty") + if not name: + raise ValueError("name cannot be empty") + if status is None: + raise ValueError("status cannot be empty") + + body: dict[str, Any] = { + "id": id, + "name": name, + "description": description, + "permittedIps": permitted_ips if permitted_ips is not None else [], + "status": status.value, + } + + response = await self._http.patch( + MgmtV1.mgmt_key_update_path, + body=body, + ) + return response.json() + + async def load( + self, + id: str, + ) -> dict: + """ + Get a management key by ID. + + Args: + id (str): The id of the management key to load. + + Return value (dict): + Return dict in the format + {"key": {...}} + Containing the loaded management key information. + + Raise: + AuthException: raised if load operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + response = await self._http.get( + uri=MgmtV1.mgmt_key_load_path, + params={"id": id}, + ) + return response.json() + + async def delete( + self, + ids: List[str], + ) -> dict: + """ + Delete existing management keys. IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): The ids of the management keys to delete. + + Return value (dict): + Return dict in the format + {"total": } + Containing the number of keys deleted. + + Raise: + AuthException: raised if delete operation fails + """ + if not ids: + raise ValueError("ids list cannot be empty") + + response = await self._http.post( + uri=MgmtV1.mgmt_key_delete_path, + body={"ids": ids}, + ) + return response.json() + + async def search(self) -> dict: + """ + Search for management keys. + + Return value (dict): + Return dict in the format + { + "keys": [...] + } + Containing the found management keys. + + Raise: + AuthException: raised if search operation fails + """ + response = await self._http.get( + MgmtV1.mgmt_key_search_path, + ) + return response.json() diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index 5bb40571a..cd51dca60 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -1,10 +1,11 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import List, Optional from descope._http_base import HTTPBase from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 from descope.http_client import HTTPClient +from descope.management._outbound_application_base import OutboundApplicationBase from descope.management.common import ( AccessType, MgmtV1, @@ -114,7 +115,7 @@ def fetch_tenant_token( return response.json() -class OutboundApplication(HTTPBase): +class OutboundApplication(OutboundApplicationBase, HTTPBase): def create_application( self, name: str, @@ -170,7 +171,7 @@ def create_application( uri = MgmtV1.outbound_application_create_path response = self._http.post( uri, - body=OutboundApplication._compose_create_update_body( + body=OutboundApplicationBase._compose_create_update_body( name, description, logo, @@ -249,7 +250,7 @@ def update_application( response = self._http.post( uri, body={ - "app": OutboundApplication._compose_create_update_body( + "app": OutboundApplicationBase._compose_create_update_body( name, description, logo, @@ -489,63 +490,6 @@ def delete_token( uri = MgmtV1.outbound_application_delete_token_path self._http.delete(uri, params={"id": token_id}) - @staticmethod - def _compose_create_update_body( - name: str, - description: Optional[str] = None, - logo: Optional[str] = None, - id: Optional[str] = None, - client_secret: Optional[str] = None, - client_id: Optional[str] = None, - discovery_url: Optional[str] = None, - authorization_url: Optional[str] = None, - authorization_url_params: Optional[List[URLParam]] = None, - token_url: Optional[str] = None, - token_url_params: Optional[List[URLParam]] = None, - revocation_url: Optional[str] = None, - default_scopes: Optional[List[str]] = None, - default_redirect_url: Optional[str] = None, - callback_domain: Optional[str] = None, - pkce: Optional[bool] = None, - access_type: Optional[AccessType] = None, - prompt: Optional[List[PromptType]] = None, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "description": description, - "logo": logo, - } - if client_secret: - body["clientSecret"] = client_secret - if client_id: - body["clientId"] = client_id - if discovery_url: - body["discoveryUrl"] = discovery_url - if authorization_url: - body["authorizationUrl"] = authorization_url - if authorization_url_params is not None: - body["authorizationUrlParams"] = url_params_to_dict(authorization_url_params) - if token_url: - body["tokenUrl"] = token_url - if token_url_params is not None: - body["tokenUrlParams"] = url_params_to_dict(token_url_params) - if revocation_url: - body["revocationUrl"] = revocation_url - if default_scopes is not None: - body["defaultScopes"] = default_scopes - if default_redirect_url: - body["defaultRedirectUrl"] = default_redirect_url - if callback_domain: - body["callbackDomain"] = callback_domain - if pkce is not None: - body["pkce"] = pkce - if access_type: - body["accessType"] = access_type.value - if prompt is not None: - body["prompt"] = [p.value for p in prompt] - return body - class OutboundApplicationByToken(HTTPBase): def __init__(self, http_client: HTTPClient): diff --git a/descope/management/outbound_application_async.py b/descope/management/outbound_application_async.py new file mode 100644 index 000000000..d45d1c884 --- /dev/null +++ b/descope/management/outbound_application_async.py @@ -0,0 +1,655 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # noqa: F401 +from descope.http_client_async import HTTPClientAsync +from descope.management._outbound_application_base import OutboundApplicationBase +from descope.management.common import ( + AccessType, + MgmtV1, + PromptType, + URLParam, +) + + +class _OutboundApplicationTokenFetcherAsync: + """Internal async helper class for shared token fetching logic.""" + + @staticmethod + async def fetch_token_by_scopes( + *, + http: HTTPClientAsync, + token: Optional[str] = None, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """Internal async implementation for fetching token by scopes.""" + uri = MgmtV1.outbound_application_fetch_token_by_scopes_path + response = await http.post( + uri, + body={ + "appId": app_id, + "userId": user_id, + "scopes": scopes, + "options": options, + "tenantId": tenant_id, + }, + pswd=token, + ) + return response.json() + + @staticmethod + async def fetch_token( + *, + http: HTTPClientAsync, + token: Optional[str] = None, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """Internal async implementation for fetching token.""" + uri = MgmtV1.outbound_application_fetch_token_path + response = await http.post( + uri, + body={ + "appId": app_id, + "userId": user_id, + "tenantId": tenant_id, + "options": options, + }, + pswd=token, + ) + return response.json() + + @staticmethod + async def fetch_tenant_token_by_scopes( + *, + http: HTTPClientAsync, + token: Optional[str] = None, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """Internal async implementation for fetching tenant token by scopes.""" + uri = MgmtV1.outbound_application_fetch_tenant_token_by_scopes_path + response = await http.post( + uri, + body={ + "appId": app_id, + "tenantId": tenant_id, + "scopes": scopes, + "options": options, + }, + pswd=token, + ) + return response.json() + + @staticmethod + async def fetch_tenant_token( + *, + http: HTTPClientAsync, + token: Optional[str] = None, + app_id: str, + tenant_id: str, + options: Optional[dict] = None, + ) -> dict: + """Internal async implementation for fetching tenant token.""" + uri = MgmtV1.outbound_application_fetch_tenant_token_path + response = await http.post( + uri, + body={ + "appId": app_id, + "tenantId": tenant_id, + "options": options, + }, + pswd=token, + ) + return response.json() + + +class OutboundApplicationAsync(OutboundApplicationBase, AsyncHTTPBase): + async def create_application( + self, + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + id: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + """ + Create a new outbound application with the given name. Outbound application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The outbound application's name. + description (str): Optional outbound application description. + logo (str): Optional outbound application logo. + id (str): Optional outbound application ID. + client_secret (str): Optional client secret for the application. + client_id (str): Optional client ID for the application. + discovery_url (str): Optional OAuth discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[URLParam]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[URLParam]): Optional token URL parameters. + revocation_url (str): Optional OAuth token revocation URL. + default_scopes (List[str]): Optional default OAuth scopes. + default_redirect_url (str): Optional default redirect URL. + callback_domain (str): Optional callback domain. + pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. + access_type (AccessType): Optional OAuth access type. + prompt (List[PromptType]): Optional OAuth prompt parameters. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + + Raise: + AuthException: raised if create operation fails + """ + uri = MgmtV1.outbound_application_create_path + response = await self._http.post( + uri, + body=OutboundApplicationBase._compose_create_update_body( + name, + description, + logo, + id, + client_secret, + client_id, + discovery_url, + authorization_url, + authorization_url_params, + token_url, + token_url_params, + revocation_url, + default_scopes, + default_redirect_url, + callback_domain, + pkce, + access_type, + prompt, + ), + ) + return response.json() + + async def update_application( + self, + id: str, + name: str, + description: Optional[str] = None, + logo: Optional[str] = None, + client_secret: Optional[str] = None, + client_id: Optional[str] = None, + discovery_url: Optional[str] = None, + authorization_url: Optional[str] = None, + authorization_url_params: Optional[List[URLParam]] = None, + token_url: Optional[str] = None, + token_url_params: Optional[List[URLParam]] = None, + revocation_url: Optional[str] = None, + default_scopes: Optional[List[str]] = None, + default_redirect_url: Optional[str] = None, + callback_domain: Optional[str] = None, + pkce: Optional[bool] = None, + access_type: Optional[AccessType] = None, + prompt: Optional[List[PromptType]] = None, + ) -> dict: + """ + Update an existing outbound application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing outbound application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the outbound application to update. + name (str): Updated outbound application name. + description (str): Optional outbound application description. + logo (str): Optional outbound application logo. + client_secret (str): Optional client secret for the application. + client_id (str): Optional client ID for the application. + discovery_url (str): Optional OAuth discovery URL. + authorization_url (str): Optional OAuth authorization URL. + authorization_url_params (List[URLParam]): Optional authorization URL parameters. + token_url (str): Optional OAuth token URL. + token_url_params (List[URLParam]): Optional token URL parameters. + revocation_url (str): Optional OAuth token revocation URL. + default_scopes (List[str]): Optional default OAuth scopes. + default_redirect_url (str): Optional default redirect URL. + callback_domain (str): Optional callback domain. + pkce (bool): Optional PKCE (Proof Key for Code Exchange) support. + access_type (AccessType): Optional OAuth access type. + prompt (List[PromptType]): Optional OAuth prompt parameters. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + + Raise: + AuthException: raised if update operation fails + """ + uri = MgmtV1.outbound_application_update_path + response = await self._http.post( + uri, + body={ + "app": OutboundApplicationBase._compose_create_update_body( + name, + description, + logo, + id, + client_secret, + client_id, + discovery_url, + authorization_url, + authorization_url_params, + token_url, + token_url_params, + revocation_url, + default_scopes, + default_redirect_url, + callback_domain, + pkce, + access_type, + prompt, + ) + }, + ) + return response.json() + + async def delete_application( + self, + id: str, + ): + """ + Delete an existing outbound application. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the outbound application that's to be deleted. + + Raise: + AuthException: raised if deletion operation fails + """ + uri = MgmtV1.outbound_application_delete_path + await self._http.post(uri, body={"id": id}) + + async def load_application( + self, + id: str, + ) -> dict: + """ + Load outbound application by id. + + Args: + id (str): The ID of the outbound application to load. + + Return value (dict): + Return dict in the format + {"app": {"id": , "name": , "description": , "logo": }} + Containing the loaded outbound application information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(f"{MgmtV1.outbound_application_load_path}/{id}") + return response.json() + + async def load_all_applications(self) -> dict: + """ + Load all outbound applications. + + Return value (dict): + Return dict in the format + {"apps": [{"id": , "name": , "description": , "logo": }, ...]} + Containing the loaded outbound applications information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.outbound_application_load_all_path) + return response.json() + + async def fetch_token_by_scopes( + self, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Fetch an outbound application token for a user with specific scopes. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + tenant_id (str): Optional tenant ID. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( + http=self._http, + app_id=app_id, + user_id=user_id, + scopes=scopes, + options=options, + tenant_id=tenant_id, + ) + + async def fetch_token( + self, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a user. + + Args: + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + return await _OutboundApplicationTokenFetcherAsync.fetch_token( + http=self._http, + app_id=app_id, + user_id=user_id, + tenant_id=tenant_id, + options=options, + ) + + async def fetch_tenant_token_by_scopes( + self, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant with specific scopes. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( + http=self._http, + app_id=app_id, + tenant_id=tenant_id, + scopes=scopes, + options=options, + ) + + async def fetch_tenant_token( + self, + app_id: str, + tenant_id: str, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant. + + Args: + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( + http=self._http, + app_id=app_id, + tenant_id=tenant_id, + options=options, + ) + + async def delete_user_tokens( + self, + app_id: Optional[str] = None, + user_id: Optional[str] = None, + ): + """ + Delete outbound application tokens by app ID and/or user ID. + At least one of app_id or user_id must be provided. + + Args: + app_id (str): Optional ID of the outbound application. + user_id (str): Optional ID of the user. + + Raise: + AuthException: raised if delete operation fails + """ + params = {} + if app_id: + params["appId"] = app_id + if user_id: + params["userId"] = user_id + uri = MgmtV1.outbound_application_delete_user_tokens_path + await self._http.delete(uri, params=params) + + async def delete_token( + self, + token_id: str, + ): + """ + Delete an outbound application token by its ID. + + Args: + token_id (str): The ID of the token to delete. + + Raise: + AuthException: raised if delete operation fails + """ + uri = MgmtV1.outbound_application_delete_token_path + await self._http.delete(uri, params={"id": token_id}) + + +class OutboundApplicationByTokenAsync(AsyncHTTPBase): + def __init__(self, http_client: HTTPClientAsync): + # This class expects the token to be passed for each call + no_key_client = HTTPClientAsync( + project_id=http_client.project_id, + base_url=http_client.base_url, + timeout_seconds=http_client.timeout_seconds, + secure=http_client.secure, + management_key=None, # Override the management key for this client + ) + super().__init__(no_key_client) + + # Methods for fetching outbound application tokens using an inbound application token + # that includes the "outbound.token.fetch" scope (no management key required) + + def _check_inbound_app_token(self, token: str): + """Check if inbound app token is available for the given property.""" + if not token: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "Inbound app token is required for perform this functionality", + ) + + async def fetch_token_by_scopes( + self, + token: str, + app_id: str, + user_id: str, + scopes: List[str], + options: Optional[dict] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Fetch an outbound application token for a user with specific scopes. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + tenant_id (str): Optional tenant ID. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return await _OutboundApplicationTokenFetcherAsync.fetch_token_by_scopes( + http=self._http, + token=token, + app_id=app_id, + user_id=user_id, + scopes=scopes, + options=options, + tenant_id=tenant_id, + ) + + async def fetch_token( + self, + token: str, + app_id: str, + user_id: str, + tenant_id: Optional[str] = None, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a user. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + user_id (str): The ID of the user. + tenant_id (str): Optional tenant ID. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return await _OutboundApplicationTokenFetcherAsync.fetch_token( + http=self._http, + token=token, + app_id=app_id, + user_id=user_id, + tenant_id=tenant_id, + options=options, + ) + + async def fetch_tenant_token_by_scopes( + self, + token: str, + app_id: str, + tenant_id: str, + scopes: List[str], + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant with specific scopes. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + scopes (List[str]): List of scopes to include in the token. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token_by_scopes( + http=self._http, + token=token, + app_id=app_id, + tenant_id=tenant_id, + scopes=scopes, + options=options, + ) + + async def fetch_tenant_token( + self, + token: str, + app_id: str, + tenant_id: str, + options: Optional[dict] = None, + ) -> dict: + """ + Fetch an outbound application token for a tenant. + + Args: + token (str): The Inbound Application token to use for authentication. + app_id (str): The ID of the outbound application. + tenant_id (str): The ID of the tenant. + options (dict): Optional token options. + + Return value (dict): + Return dict in the format + {"token": {"token": , "refreshToken": , "expiresIn": , "tokenType": , "scopes": }} + + Raise: + AuthException: raised if fetch operation fails + """ + self._check_inbound_app_token(token) + return await _OutboundApplicationTokenFetcherAsync.fetch_tenant_token( + http=self._http, + token=token, + app_id=app_id, + tenant_id=tenant_id, + options=options, + ) diff --git a/descope/management/permission_async.py b/descope/management/permission_async.py new file mode 100644 index 000000000..daa1946f1 --- /dev/null +++ b/descope/management/permission_async.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class PermissionAsync(AsyncHTTPBase): + """Async counterpart of Permission — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + description: Optional[str] = None, + ): + """ + Create a new permission. + + Args: + name (str): permission name. + description (str): Optional description to briefly explain what this permission allows. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.permission_create_path, + body={"name": name, "description": description}, + ) + + async def create_batch( + self, + permissions: List[dict], + ): + """ + Create a batch of permissions in a single atomic transaction. + + Args: + permissions (List[dict]): List of permission objects, each with: + - name (str): permission name. + - description (str): Optional description. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.permission_create_batch_path, + body={"permissions": permissions}, + ) + + async def update_batch( + self, + permissions: List[dict], + ): + """ + Update a batch of permissions in a single atomic transaction. + + Args: + permissions (List[dict]): List of permission objects, each with: + - name (str): current permission name (or id (str): permission ID, e.g. PERM...). + - newName (str): new permission name. + - description (str): Optional new description. + + Raise: + AuthException: raised if update operation fails + """ + await self._http.post( + MgmtV1.permission_update_batch_path, + body={"permissions": permissions}, + ) + + async def delete_batch( + self, + names: List[str], + ): + """ + Delete a batch of permissions in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + names (List[str]): List of permission names to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.permission_delete_batch_path, + body={"names": names}, + ) + + async def delete_batch_by_ids( + self, + ids: List[str], + ): + """ + Delete a batch of permissions by their IDs in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): List of permission IDs to delete (e.g. PERM...). + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.permission_delete_batch_path, + body={"ids": ids}, + ) + + async def update( + self, + name: str, + new_name: str, + description: Optional[str] = None, + ): + """ + Update an existing permission with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing permission. Empty fields will override populated fields. Use carefully. + + Args: + name (str): permission name. + new_name (str): permission updated name. + description (str): Optional description to briefly explain what this permission allows. + + Raise: + AuthException: raised if update operation fails + """ + await self._http.post( + MgmtV1.permission_update_path, + body={"name": name, "newName": new_name, "description": description}, + ) + + async def update_by_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + ): + """ + Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing permission. Empty fields will override populated fields. Use carefully. + + Args: + id (str): permission ID (e.g. PERM...). + new_name (str): permission updated name. + description (str): Optional description to briefly explain what this permission allows. + + Raise: + AuthException: raised if update operation fails + """ + await self._http.post( + MgmtV1.permission_update_path, + body={"id": id, "newName": new_name, "description": description}, + ) + + async def delete( + self, + name: str, + ): + """ + Delete an existing permission. IMPORTANT: This action is irreversible. Use carefully. + + Args: + name (str): The name of the permission to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.permission_delete_path, + body={"name": name}, + ) + + async def delete_by_id( + self, + id: str, + ): + """ + Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the permission to be deleted (e.g. PERM...). + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.permission_delete_path, + body={"id": id}, + ) + + async def load_all( + self, + ) -> dict: + """ + Load all permissions. + + Return value (dict): + Return dict in the format + {"permissions": [{"name": , "description": , "systemDefault":}]} + Containing the loaded permission information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + MgmtV1.permission_load_all_path, + ) + return response.json() diff --git a/descope/management/project_async.py b/descope/management/project_async.py new file mode 100644 index 000000000..db2d6f98a --- /dev/null +++ b/descope/management/project_async.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class ProjectAsync(AsyncHTTPBase): + """Async counterpart of Project — all HTTP calls are coroutines.""" + + async def update_name( + self, + name: str, + ): + """ + Update the current project name. + + Args: + name (str): The new name for the project. + Raise: + AuthException: raised if operation fails + """ + await self._http.post( + MgmtV1.project_update_name, + body={ + "name": name, + }, + ) + + async def update_tags( + self, + tags: List[str], + ): + """ + Update the current project tags. + + Args: + tags (List[str]): Array of free text tags. + Raise: + AuthException: raised if operation fails + """ + await self._http.post( + MgmtV1.project_update_tags, + body={ + "tags": tags, + }, + ) + + async def list_projects( + self, + ) -> dict: + """ + List of all the projects in the company. + + Return value (dict): + Return dict in the format + {"projects": []} + "projects" contains a list of all of the projects and their information + + Raise: + AuthException: raised if operation fails + """ + response = await self._http.post( + MgmtV1.project_list_projects, + body={}, + ) + resp = response.json() + + projects = resp["projects"] + # Apply the function to the projects list + formatted_projects = self.remove_tag_field(projects) + + # Return the same structure with 'tag' removed + result = {"projects": formatted_projects} + return result + + async def clone( + self, + name: str, + environment: Optional[str] = None, + tags: Optional[List[str]] = None, + ): + """ + Clone the current project, including its settings and configurations. + - This action is supported only with a pro license or above. + - Users, tenants and access keys are not cloned. + + Args: + name (str): The new name for the project. + environment (str): Optional state for the project. Currently, only the "production" tag is supported. + tags(list[str]): Optional free text tags. + + Return value (dict): + Return dict Containing the new project details (name, id, environment and tag). + + Raise: + AuthException: raised if clone operation fails + """ + response = await self._http.post( + MgmtV1.project_clone, + body={ + "name": name, + "environment": environment, + "tags": tags, + }, + ) + return response.json() + + async def export_project( + self, + ): + """ + Exports all settings and configurations for a project and returns the + raw JSON files response as a dictionary. + - This action is supported only with a pro license or above. + - Users, tenants and access keys are not cloned. + - Secrets, keys and tokens are not stripped from the exported data. + + Return value (dict): + Return dict Containing the exported JSON files payload. + + Raise: + AuthException: raised if export operation fails + """ + response = await self._http.post( + MgmtV1.project_export, + body={}, + ) + return response.json()["files"] + + async def import_project( + self, + files: dict, + ): + """ + Imports all settings and configurations for a project overriding any current + configuration. + - This action is supported only with a pro license or above. + - Secrets, keys and tokens are not overwritten unless overwritten in the input. + + Args: + files (dict): The raw JSON dictionary of files, in the same format as the one + returned by calls to export. + + Raise: + AuthException: raised if import operation fails + """ + await self._http.post( + MgmtV1.project_import, + body={ + "files": files, + }, + ) + return + + # Function to remove 'tag' field from each project + def remove_tag_field(self, projects): + return [{k: v for k, v in project.items() if k != "tag"} for project in projects] diff --git a/descope/management/role_async.py b/descope/management/role_async.py new file mode 100644 index 000000000..2ddffedf0 --- /dev/null +++ b/descope/management/role_async.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class RoleAsync(AsyncHTTPBase): + """Async counterpart of Role — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + description: Optional[str] = None, + permission_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + default: Optional[bool] = None, + private: Optional[bool] = None, + ): + """ + Create a new role. + + Args: + name (str): role name. + description (str): Optional description to briefly explain what this role allows. + permission_names (List[str]): Optional list of names of permissions this role grants. + tenant_id (str): Optional tenant ID to create the role in. + default (bool): Optional marks this role as default role. + private (bool): Optional marks this role as private role. + + Raise: + AuthException: raised if creation operation fails + """ + permission_names = [] if permission_names is None else permission_names + + await self._http.post( + MgmtV1.role_create_path, + body={ + "name": name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, + ) + + async def create_batch( + self, + roles: List[dict], + ): + """ + Create a batch of roles in a single atomic transaction. + + Args: + roles (List[dict]): List of role objects, each with: + - name (str): role name. + - description (str): Optional description. + - permissionNames (List[str]): Optional list of permission names. + - tenantId (str): Optional tenant ID. + - default (bool): Optional default flag. + - private (bool): Optional private flag. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.role_create_batch_path, + body={"roles": roles}, + ) + + async def update_batch( + self, + roles: List[dict], + ): + """ + Update a batch of roles in a single atomic transaction. + + Args: + roles (List[dict]): List of role objects, each with: + - name (str): current role name (or id (str): role ID, e.g. ROL...). + - newName (str): new role name. + - description (str): Optional new description. + - permissionNames (List[str]): Optional list of permission names. + - tenantId (str): Optional tenant ID. + - default (bool): Optional default flag. + - private (bool): Optional private flag. + + Raise: + AuthException: raised if update operation fails + """ + await self._http.post( + MgmtV1.role_update_batch_path, + body={"roles": roles}, + ) + + async def delete_batch( + self, + roles: List[dict], + ): + """ + Delete a batch of roles in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + roles (List[dict]): List of role objects to delete, each with: + - name (str): role name. + - tenantId (str): Optional tenant ID. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.role_delete_batch_path, + body={"roles": roles}, + ) + + async def delete_batch_by_ids( + self, + role_ids: List[str], + tenant_id: Optional[str] = None, + ): + """ + Delete a batch of roles by their IDs in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + role_ids (List[str]): List of role IDs to delete (e.g. ROL...). + tenant_id (str): Optional tenant ID the roles belong to. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.role_delete_batch_path, + body={"roleIds": role_ids, "tenantId": tenant_id}, + ) + + async def update( + self, + name: str, + new_name: str, + description: Optional[str] = None, + permission_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + default: Optional[bool] = None, + private: Optional[bool] = None, + ): + """ + Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing role. Empty fields will override populated fields. Use carefully. + + Args: + name (str): role name. + new_name (str): role updated name. + description (str): Optional description to briefly explain what this role allows. + permission_names (List[str]): Optional list of names of permissions this role grants. + tenant_id (str): Optional tenant ID to update the role in. + default (bool): Optional marks this role as default role. + private (bool): Optional marks this role as private role. + + Raise: + AuthException: raised if update operation fails + """ + permission_names = [] if permission_names is None else permission_names + await self._http.post( + MgmtV1.role_update_path, + body={ + "name": name, + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, + ) + + async def update_by_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + permission_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + default: Optional[bool] = None, + private: Optional[bool] = None, + ): + """ + Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing role. Empty fields will override populated fields. Use carefully. + + Args: + id (str): role ID (e.g. ROL...). + new_name (str): role updated name. + description (str): Optional description to briefly explain what this role allows. + permission_names (List[str]): Optional list of names of permissions this role grants. + tenant_id (str): Optional tenant ID to update the role in. + default (bool): Optional marks this role as default role. + private (bool): Optional marks this role as private role. + + Raise: + AuthException: raised if update operation fails + """ + permission_names = [] if permission_names is None else permission_names + await self._http.post( + MgmtV1.role_update_path, + body={ + "id": id, + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, + ) + + async def delete( + self, + name: str, + tenant_id: Optional[str] = None, + ): + """ + Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. + + Args: + name (str): The name of the role to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.role_delete_path, + body={"name": name, "tenantId": tenant_id}, + ) + + async def delete_by_id( + self, + id: str, + tenant_id: Optional[str] = None, + ): + """ + Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the role to be deleted (e.g. ROL...). + tenant_id (str): Optional tenant ID the role belongs to. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post( + MgmtV1.role_delete_path, + body={"id": id, "tenantId": tenant_id}, + ) + + async def load_all( + self, + ) -> dict: + """ + Load all roles. + + Return value (dict): + Return dict in the format + {"roles": [{"name": , "description": , "permissionNames":[]}] } + Containing the loaded role information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + MgmtV1.role_load_all_path, + ) + return response.json() + + async def search( + self, + tenant_ids: Optional[List[str]] = None, + role_names: Optional[List[str]] = None, + role_name_like: Optional[str] = None, + permission_names: Optional[List[str]] = None, + include_project_roles: Optional[bool] = None, + role_ids: Optional[List[str]] = None, + ) -> dict: + """ + Search roles based on the given filters. + + Args: + tenant_ids (List[str]): List of tenant ids to filter by + role_names (List[str]): Only return matching roles to the given names + role_name_like (str): Return roles that contain the given string ignoring case + permission_names (List[str]): Only return roles that have the given permissions + role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) + + Return value (dict): + Return dict in the format + {"roles": [{"id": , "name": , "description": , "permissionNames":[]}] } + Containing the loaded role information. + + Raise: + AuthException: raised if load operation fails + """ + body: dict[str, str | bool | List[str]] = {} + if tenant_ids is not None: + body["tenantIds"] = tenant_ids + if role_names is not None: + body["roleNames"] = role_names + if role_name_like is not None: + body["roleNameLike"] = role_name_like + if permission_names is not None: + body["permissionNames"] = permission_names + if include_project_roles is not None: + body["includeProjectRoles"] = include_project_roles + if role_ids is not None: + body["roleIds"] = role_ids + + response = await self._http.post( + MgmtV1.role_search_path, + body=body, + ) + return response.json() diff --git a/descope/management/sso_application_async.py b/descope/management/sso_application_async.py new file mode 100644 index 000000000..9918e9bac --- /dev/null +++ b/descope/management/sso_application_async.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import ( + MgmtV1, + SAMLIDPAttributeMappingInfo, + SAMLIDPGroupsMappingInfo, + saml_idp_attribute_mapping_info_to_dict, + saml_idp_groups_mapping_info_to_dict, +) + + +class SSOApplicationAsync(AsyncHTTPBase): + """Async counterpart of SSOApplication — all HTTP calls are coroutines.""" + + async def create_oidc_application( + self, + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + force_authentication: Optional[bool] = False, + ) -> dict: + """ + Create a new OIDC sso application with the given name. SSO application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The sso application's name. + login_page_url (str): The URL where login page is hosted. + id (str): Optional sso application ID. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional (default True) does the sso application will be enabled or disabled. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + + Return value (dict): + Return dict in the format + {"id": } + + Raise: + AuthException: raised if create operation fails + """ + uri = MgmtV1.sso_application_oidc_create_path + response = await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_oidc_body( + name, + login_page_url, + id, + description, + logo, + enabled, + force_authentication, + ), + ) + return response.json() + + async def create_saml_application( + self, + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + use_metadata_info: Optional[bool] = False, + metadata_url: Optional[str] = None, + entity_id: Optional[str] = None, + acs_url: Optional[str] = None, + certificate: Optional[str] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + acs_allowed_callbacks: Optional[List[str]] = None, + subject_name_id_type: Optional[str] = None, + subject_name_id_format: Optional[str] = None, + default_relay_state: Optional[str] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + default_signature_algorithm: Optional[str] = None, + ) -> dict: + """ + Create a new SAML sso application with the given name. SSO application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The sso application's name. + login_page_url (str): The URL where login page is hosted. + id (str): Optional sso application ID. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional set the sso application as enabled or disabled. + use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. + metadata_url (str): Optional SP metadata url which include all the SP SAML info. + entity_id (str): Optional SP entity id. + acs_url (str): Optional SP ACS (saml callback) url. + certificate (str): Optional SP certificate, relevant only when SAML request must be signed. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. + subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". + subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". + default_relay_state (str): Optional define the default relay state. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. + + Return value (dict): + Return dict in the format + {"id": } + + Raise: + AuthException: raised if create operation fails + """ + + if use_metadata_info: + if not metadata_url: + raise Exception("metadata_url argument must be set") + else: + if not entity_id or not acs_url or not certificate: + raise Exception("entity_id, acs_url, certificate arguments must be set") + + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + + groups_mapping = [] if groups_mapping is None else groups_mapping + + acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks + + uri = MgmtV1.sso_application_saml_create_path + response = await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_saml_body( + name, + login_page_url, + id, + description, + enabled, + logo, + use_metadata_info, + metadata_url, + entity_id, + acs_url, + certificate, + attribute_mapping, + groups_mapping, + acs_allowed_callbacks, + subject_name_id_type, + subject_name_id_format, + default_relay_state, + force_authentication, + logout_redirect_url, + default_signature_algorithm, + ), + ) + return response.json() + + async def update_oidc_application( + self, + id: str, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + force_authentication: Optional[bool] = False, + ): + """ + Update an existing OIDC sso application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing sso application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the sso application to update. + name (str): Updated sso application name + login_page_url (str): The URL where login page is hosted. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional (default True) does the sso application will be enabled or disabled. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + + Raise: + AuthException: raised if update operation fails + """ + + uri = MgmtV1.sso_application_oidc_update_path + await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_oidc_body( + name, + login_page_url, + id, + description, + logo, + enabled, + force_authentication, + ), + ) + + async def update_saml_application( + self, + id: str, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + use_metadata_info: Optional[bool] = False, + metadata_url: Optional[str] = None, + entity_id: Optional[str] = None, + acs_url: Optional[str] = None, + certificate: Optional[str] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + acs_allowed_callbacks: Optional[List[str]] = None, + subject_name_id_type: Optional[str] = None, + subject_name_id_format: Optional[str] = None, + default_relay_state: Optional[str] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + default_signature_algorithm: Optional[str] = None, + ): + """ + Update an existing SAML sso application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing sso application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the sso application to update. + name (str): Updated sso application name + login_page_url (str): The URL where login page is hosted. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional (default True) does the sso application will be enabled or disabled. + use_metadata_info (bool): Optional determine if SP info should be automatically fetched from metadata_url or by specified it by the entity_id, acs_url, certificate parameters. + metadata_url (str): Optional SP metadata url which include all the SP SAML info. + entity_id (str): Optional SP entity id. + acs_url (str): Optional SP ACS (saml callback) url. + certificate (str): Optional SP certificate, relevant only when SAML request must be signed. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + acs_allowed_callbacks (List[str]): Optional list of urls wildcards strings represents the allowed ACS urls that will be accepted while arriving on the SAML request as SP callback urls. + subject_name_id_type (str): Optional define the SAML Assertion subject name type, leave empty for using Descope user-id or set to "email"/"phone". + subject_name_id_format (str): Optional define the SAML Assertion subject name format, leave empty for using "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified". + default_relay_state (str): Optional define the default relay state. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + default_signature_algorithm (str): Optional signature algorithm for SAML responses. Use "sha256" to opt in to SHA-256. Leave empty for the default (SHA-1). Only applies to IdP-initiated flows. + + Raise: + AuthException: raised if update operation fails + """ + + if use_metadata_info: + if not metadata_url: + raise Exception("metadata_url argument must be set") + else: + if not entity_id or not acs_url or not certificate: + raise Exception("entity_id, acs_url, certificate arguments must be set") + + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + + groups_mapping = [] if groups_mapping is None else groups_mapping + + acs_allowed_callbacks = [] if acs_allowed_callbacks is None else acs_allowed_callbacks + + uri = MgmtV1.sso_application_saml_update_path + await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_saml_body( + name, + login_page_url, + id, + description, + enabled, + logo, + use_metadata_info, + metadata_url, + entity_id, + acs_url, + certificate, + attribute_mapping, + groups_mapping, + acs_allowed_callbacks, + subject_name_id_type, + subject_name_id_format, + default_relay_state, + force_authentication, + logout_redirect_url, + default_signature_algorithm, + ), + ) + + async def delete( + self, + id: str, + ): + """ + Delete an existing sso application. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the sso application that's to be deleted. + + Raise: + AuthException: raised if deletion operation fails + """ + uri = MgmtV1.sso_application_delete_path + # Using adapter's do_post which already includes management key in Authorization header + await self._http.post(uri, body={"id": id}) + + async def load( + self, + id: str, + ) -> dict: + """ + Load sso application by id. + + Args: + id (str): The ID of the sso application to load. + + Return value (dict): + Return dict in the format + {"id":"","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} + Containing the loaded sso application information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.sso_application_load_path, params={"id": id}) + return response.json() + + async def load_all( + self, + ) -> dict: + """ + Load all sso applications. + + Return value (dict): + Return dict in the format + { + "apps": [ + {"id":"app1","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}}, + {"id":"app2","name":"","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":true,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":"", "defaultRelayState":"", "forceAuthentication": false, "idpLogoutUrl": "", "logoutRedirectUrl": ""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":"", "forceAuthentication":false}} + ] + } + Containing the loaded sso applications information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.sso_application_load_all_path) + return response.json() + + @staticmethod + def _compose_create_update_oidc_body( + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + force_authentication: Optional[bool] = False, + ) -> dict: + body: dict[str, Any] = { + "name": name, + "id": id, + "description": description, + "logo": logo, + "enabled": enabled, + "loginPageUrl": login_page_url, + "forceAuthentication": force_authentication, + } + return body + + @staticmethod + def _compose_create_update_saml_body( + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + enabled: Optional[bool] = True, + logo: Optional[str] = None, + use_metadata_info: Optional[bool] = False, + metadata_url: Optional[str] = None, + entity_id: Optional[str] = None, + acs_url: Optional[str] = None, + certificate: Optional[str] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + acs_allowed_callbacks: Optional[List[str]] = None, + subject_name_id_type: Optional[str] = None, + subject_name_id_format: Optional[str] = None, + default_relay_state: Optional[str] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + default_signature_algorithm: Optional[str] = None, + ) -> dict: + body: dict[str, Any] = { + "id": id, + "name": name, + "description": description, + "enabled": enabled, + "logo": logo, + "loginPageUrl": login_page_url, + "useMetadataInfo": use_metadata_info, + "metadataUrl": metadata_url, + "entityId": entity_id, + "acsUrl": acs_url, + "certificate": certificate, + "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), + "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), + "acsAllowedCallbacks": acs_allowed_callbacks, + "subjectNameIdType": subject_name_id_type, + "subjectNameIdFormat": subject_name_id_format, + "defaultRelayState": default_relay_state, + "forceAuthentication": force_authentication, + "logoutRedirectUrl": logout_redirect_url, + "defaultSignatureAlgorithm": default_signature_algorithm, + } + + return body diff --git a/descope/management/sso_settings_async.py b/descope/management/sso_settings_async.py new file mode 100644 index 000000000..52b04d4ba --- /dev/null +++ b/descope/management/sso_settings_async.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 +from descope.management.sso_settings import ( + AttributeMapping, + FGAGroupMapping, + OIDCAttributeMapping, + RoleMapping, + SSOOIDCSettings, + SSOSAMLSettings, + SSOSAMLSettingsByMetadata, +) + + +class SSOSettingsAsync(AsyncHTTPBase): + """Async counterpart of SSOSettings — all HTTP calls are coroutines.""" + + async def load_settings( + self, + tenant_id: str, + ) -> dict: + """ + Load SSO setting for the provided tenant_id. + + Args: + tenant_id (str): The tenant ID of the desired SSO Settings + + Return value (dict): + Containing the loaded SSO settings information. + Return dict in the format: + {"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": [], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": ""}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": False, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": ""}} + + Raise: + AuthException: raised if load configuration operation fails + """ + response = await self._http.get( + uri=MgmtV1.sso_load_settings_path, + params={"tenantId": tenant_id}, + ) + return response.json() + + async def recalculate_sso_mappings( + self, + tenant_id: str, + sso_id: Optional[str] = None, + ): + """ + Recalculate SSO group to role mappings for all users in a tenant. + + This method triggers a recalculation of user roles based on the current SSO group mappings. + It will update the roles for all users in the tenant who have SSO group mappings. + + Args: + tenant_id (str): The tenant ID (required) + sso_id (str): Optional, specify to recalculate mappings for a specific SSO configuration + + Raise: + AuthException: raised if recalculation operation fails + """ + body = {"tenantId": tenant_id} + if sso_id: + body["ssoId"] = sso_id + + await self._http.post( + uri=MgmtV1.sso_recalculate_mappings_path, + body=body, + ) + + async def delete_settings( + self, + tenant_id: str, + ): + """ + Delete SSO setting for the provided tenant_id. + + Args: + tenant_id (str): The tenant ID of the desired SSO Settings to delete + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.delete( + MgmtV1.sso_settings_path, + params={"tenantId": tenant_id}, + ) + + async def configure_oidc_settings( + self, + tenant_id: str, + settings: SSOOIDCSettings, + domains: Optional[List[str]] = None, + ): + """ + Configure SSO OIDC settings for a tenant. + + Args: + tenant_id (str): The tenant ID to be configured + settings (SSOOIDCSettings): The OIDC settings to be configured for this tenant (all settings parameters are required). + domains (List[str]): Optional,domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. + + Raise: + AuthException: raised if configuration operation fails + """ + + await self._http.post( + MgmtV1.sso_configure_oidc_settings, + body=SSOSettingsAsync._compose_configure_oidc_settings_body(tenant_id, settings, domains), + ) + + async def configure_saml_settings( + self, + tenant_id: str, + settings: SSOSAMLSettings, + redirect_url: Optional[str] = None, + domains: Optional[List[str]] = None, + ): + """ + Configure SSO SAML settings for a tenant. + + Args: + tenant_id (str): The tenant ID to be configured + settings (SSOSAMLSettings): The SAML settings to be configured for this tenant (all settings parameters are required). + redirect_url (str): Optional,the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). + domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. + + Raise: + AuthException: raised if configuration operation fails + """ + + await self._http.post( + MgmtV1.sso_configure_saml_settings, + body=SSOSettingsAsync._compose_configure_saml_settings_body(tenant_id, settings, redirect_url, domains), + ) + + async def configure_saml_settings_by_metadata( + self, + tenant_id: str, + settings: SSOSAMLSettingsByMetadata, + redirect_url: Optional[str] = None, + domains: Optional[List[str]] = None, + ): + """ + Configure SSO SAML settings for a tenant by fetching them from an IDP metadata URL. + + Args: + tenant_id (str): The tenant ID to be configured + settings (SSOSAMLSettingsByMetadata): The SAML settings to be configured for this tenant (all settings parameters are required). + redirect_url (str): Optional, the Redirect URL to use after successful authentication, or empty string to reset it (if not given it has to be set when starting an SSO authentication via the request). + domains (List[str]): Optional, domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. + + Raise: + AuthException: raised if configuration operation fails + """ + + await self._http.post( + MgmtV1.sso_configure_saml_by_metadata_settings, + body=SSOSettingsAsync._compose_configure_saml_settings_by_metadata_body( + tenant_id, settings, redirect_url, domains + ), + ) + + # DEPRECATED + async def get_settings( + self, + tenant_id: str, + ) -> dict: + """ + DEPRECATED (use load_settings(..) function instead) + + Get SSO setting for the provided tenant_id. + + Args: + tenant_id (str): The tenant ID of the desired SSO Settings + + Return value (dict): + Containing the loaded SSO settings information. + + Raise: + AuthException: raised if configuration operation fails + """ + response = await self._http.get( + uri=MgmtV1.sso_settings_path, + params={"tenantId": tenant_id}, + ) + return response.json() + + # DEPRECATED + async def configure( + self, + tenant_id: str, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, + domains: Optional[List[str]] = None, + ) -> None: + """ + DEPRECATED (use configure_saml_settings(..) function instead) + + Configure SSO setting for a tenant manually. Alternatively, `configure_via_metadata` can be used instead. + + Args: + tenant_id (str): The tenant ID to be configured + idp_url (str): The URL for the identity provider. + entity_id (str): The entity ID (in the IDP). + idp_cert (str): The certificate provided by the IDP. + redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. + domain (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. + + Raise: + AuthException: raised if configuration operation fails + """ + await self._http.post( + MgmtV1.sso_settings_path, + body=SSOSettingsAsync._compose_configure_body(tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains), + ) + + # DEPRECATED + async def configure_via_metadata( + self, + tenant_id: str, + idp_metadata_url: str, + redirect_url: Optional[str] = None, + domains: Optional[List[str]] = None, + ): + """ + DEPRECATED (use configure_saml_settings_by_metadata(..) function instead) + + Configure SSO setting for am IDP metadata URL. Alternatively, `configure` can be used instead. + + Args: + tenant_id (str): The tenant ID to be configured + idp_metadata_url (str): The URL to fetch SSO settings from. + redirect_url (str): The Redirect URL to use after successful authentication, or empty string to reset it. + domains (List[str]): domains used to associate users authenticating via SSO with this tenant. Use empty list or None to reset them. + + Raise: + AuthException: raised if configuration operation fails + """ + await self._http.post( + MgmtV1.sso_metadata_path, + body=SSOSettingsAsync._compose_metadata_body(tenant_id, idp_metadata_url, redirect_url, domains), + ) + + # DEPRECATED + async def mapping( + self, + tenant_id: str, + role_mappings: Optional[List[RoleMapping]] = None, + attribute_mapping: Optional[AttributeMapping] = None, + ): + """ + DEPRECATED (use configure_saml_settings(..) or configure_saml_settings_by_metadata(..) functions instead) + + Configure SSO role mapping from the IDP groups to the Descope roles. + + Args: + tenant_id (str): The tenant ID to be configured + role_mappings (List[RoleMapping]): A mapping between IDP groups and Descope roles. + attribute_mapping (AttributeMapping): A mapping between IDP user attributes and descope attributes. + + Raise: + AuthException: raised if configuration operation fails + """ + await self._http.post( + MgmtV1.sso_mapping_path, + body=SSOSettingsAsync._compose_mapping_body(tenant_id, role_mappings, attribute_mapping), + ) + + @staticmethod + def _compose_configure_body( + tenant_id: str, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, + domains: Optional[List[str]], + ) -> dict: + return { + "tenantId": tenant_id, + "idpURL": idp_url, + "entityId": entity_id, + "idpCert": idp_cert, + "redirectURL": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_metadata_body( + tenant_id: str, + idp_metadata_url: str, + redirect_url: Optional[str] = None, + domains: Optional[List[str]] = None, + ) -> dict: + return { + "tenantId": tenant_id, + "idpMetadataURL": idp_metadata_url, + "redirectURL": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_mapping_body( + tenant_id: str, + role_mapping: Optional[List[RoleMapping]], + attribute_mapping: Optional[AttributeMapping], + ) -> dict: + return { + "tenantId": tenant_id, + "roleMappings": SSOSettingsAsync._role_mapping_to_dict(role_mapping), + "attributeMapping": SSOSettingsAsync._attribute_mapping_to_dict(attribute_mapping), + } + + @staticmethod + def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: + if role_mapping is None: + role_mapping = [] + role_mapping_list = [] + for mapping in role_mapping: + role_mapping_list.append( + { + "groups": mapping.groups, + "roleName": mapping.role_name, + } + ) + return role_mapping_list + + @staticmethod + def _attribute_mapping_to_dict( + attribute_mapping: Optional[AttributeMapping], + ) -> dict: + if attribute_mapping is None: + raise ValueError("Attribute mapping cannot be None") + return { + "name": attribute_mapping.name, + "email": attribute_mapping.email, + "phoneNumber": attribute_mapping.phone_number, + "group": attribute_mapping.group, + "givenName": attribute_mapping.given_name, + "middleName": attribute_mapping.middle_name, + "familyName": attribute_mapping.family_name, + "picture": attribute_mapping.picture, + "customAttributes": attribute_mapping.custom_attributes, + } + + @staticmethod + def _fga_mappings_to_dict( + fga_mappings: Optional[Dict[str, FGAGroupMapping]], + ) -> Optional[dict]: + if fga_mappings is None: + return None + result: dict = {} + for group_name, mapping in fga_mappings.items(): + relations = [] + if mapping is not None and mapping.relations: + for relation in mapping.relations: + relations.append( + { + "resource": relation.resource, + "relationDefinition": relation.relation_definition, + "namespace": relation.namespace, + } + ) + result[group_name] = {"relations": relations} + return result + + @staticmethod + def _compose_configure_oidc_settings_body( + tenant_id: str, + settings: SSOOIDCSettings, + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = { + "loginId": settings.attribute_mapping.login_id, + "name": settings.attribute_mapping.name, + "givenName": settings.attribute_mapping.given_name, + "middleName": settings.attribute_mapping.middle_name, + "familyName": settings.attribute_mapping.family_name, + "email": settings.attribute_mapping.email, + "verifiedEmail": settings.attribute_mapping.verified_email, + "username": settings.attribute_mapping.username, + "phoneNumber": settings.attribute_mapping.phone_number, + "verifiedPhone": settings.attribute_mapping.verified_phone, + "picture": settings.attribute_mapping.picture, + } + + return { + "tenantId": tenant_id, + "settings": { + "name": settings.name, + "clientId": settings.client_id, + "clientSecret": settings.client_secret, + "redirectUrl": settings.redirect_url, + "authUrl": settings.auth_url, + "tokenUrl": settings.token_url, + "userDataUrl": settings.user_data_url, + "scope": settings.scope, + "JWKsUrl": settings.jwks_url, + "userAttrMapping": attr_mapping, + "manageProviderTokens": settings.manage_provider_tokens, + "callbackDomain": settings.callback_domain, + "prompt": settings.prompt, + "grantType": settings.grant_type, + "issuer": settings.issuer, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), + }, + "domains": domains, + } + + @staticmethod + def _compose_configure_saml_settings_body( + tenant_id: str, + settings: SSOSAMLSettings, + redirect_url: Optional[str], + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = SSOSettingsAsync._attribute_mapping_to_dict(settings.attribute_mapping) + + return { + "tenantId": tenant_id, + "settings": { + "idpUrl": settings.idp_url, + "entityId": settings.idp_entity_id, + "idpCert": settings.idp_cert, + "idpAdditionalCerts": settings.idp_additional_certs, + "spACSUrl": settings.sp_acs_url, + "spEntityId": settings.sp_entity_id, + "attributeMapping": attr_mapping, + "roleMappings": SSOSettingsAsync._role_mapping_to_dict(settings.role_mappings), + "defaultSSORoles": settings.default_sso_roles, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, + }, + "redirectUrl": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_configure_saml_settings_by_metadata_body( + tenant_id: str, + settings: SSOSAMLSettingsByMetadata, + redirect_url: Optional[str], + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = SSOSettingsAsync._attribute_mapping_to_dict(settings.attribute_mapping) + + return { + "tenantId": tenant_id, + "settings": { + "idpMetadataUrl": settings.idp_metadata_url, + "spACSUrl": settings.sp_acs_url, + "spEntityId": settings.sp_entity_id, + "attributeMapping": attr_mapping, + "roleMappings": SSOSettingsAsync._role_mapping_to_dict(settings.role_mappings), + "defaultSSORoles": settings.default_sso_roles, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, + }, + "redirectUrl": redirect_url, + "domains": domains, + } diff --git a/descope/management/tenant.py b/descope/management/tenant.py index 504a61907..f47526c7b 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -1,6 +1,7 @@ from typing import Any, List, Optional from descope._http_base import HTTPBase +from descope.management._tenant_base import TenantBase from descope.management.common import ( MgmtV1, SessionExpirationUnit, @@ -9,7 +10,7 @@ ) -class Tenant(HTTPBase): +class Tenant(TenantBase, HTTPBase): def create( self, name: str, @@ -47,7 +48,7 @@ def create( response = self._http.post( MgmtV1.tenant_create_path, - body=Tenant._compose_create_update_body( + body=TenantBase._compose_create_update_body( name, id, self_provisioning_domains, @@ -93,7 +94,7 @@ def update( self._http.post( MgmtV1.tenant_update_path, - body=Tenant._compose_create_update_body( + body=TenantBase._compose_create_update_body( name, id, self_provisioning_domains, @@ -362,29 +363,3 @@ def generate_sso_configuration_link( ) result = response.json() return result.get("adminSSOConfigurationLink", "") - - @staticmethod - def _compose_create_update_body( - name: str, - id: Optional[str], - self_provisioning_domains: List[str], - custom_attributes: Optional[dict] = None, - enforce_sso: Optional[bool] = False, - enforce_sso_exclusions: Optional[List[str]] = None, - federated_app_ids: Optional[List[str]] = None, - disabled: Optional[bool] = False, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "selfProvisioningDomains": self_provisioning_domains, - "enforceSSO": enforce_sso, - "disabled": disabled, - } - if custom_attributes is not None: - body["customAttributes"] = custom_attributes - if enforce_sso_exclusions is not None: - body["enforceSSOExclusions"] = enforce_sso_exclusions - if federated_app_ids is not None: - body["federatedAppIds"] = federated_app_ids - return body diff --git a/descope/management/tenant_async.py b/descope/management/tenant_async.py new file mode 100644 index 000000000..edd0193fa --- /dev/null +++ b/descope/management/tenant_async.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management._tenant_base import TenantBase +from descope.management.common import ( + MgmtV1, + SessionExpirationUnit, + SSOSetupSuiteSettings, + TenantAuthType, +) + + +class TenantAsync(TenantBase, AsyncHTTPBase): + """Async counterpart of Tenant — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + id: Optional[str] = None, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + enforce_sso: Optional[bool] = False, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + disabled: Optional[bool] = False, + ) -> dict: + """ + Create a new tenant with the given name. Tenant IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The tenant's name + id (str): Optional tenant ID. + self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso + enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement + federated_app_ids (List[str]): Optional, list of federated application IDs + disabled (bool): Optional, login to the tenant will be disabled + + Return value (dict): + Return dict in the format + {"id": } + + Raise: + AuthException: raised if creation operation fails + """ + self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains + + response = await self._http.post( + MgmtV1.tenant_create_path, + body=TenantBase._compose_create_update_body( + name, + id, + self_provisioning_domains, + custom_attributes, + enforce_sso, + enforce_sso_exclusions, + federated_app_ids, + disabled, + ), + ) + return response.json() + + async def update( + self, + id: str, + name: str, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + enforce_sso: Optional[bool] = False, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + disabled: Optional[bool] = False, + ): + """ + Update an existing tenant with the given name and domains. IMPORTANT: All parameters are used as overrides + to the existing tenant. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the tenant to update. + name (str): Updated tenant name + self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso + enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement + federated_app_ids (List[str]): Optional, list of federated application IDs + disabled (bool): Optional, login to the tenant will be disabled + + Raise: + AuthException: raised if creation operation fails + """ + self_provisioning_domains = [] if self_provisioning_domains is None else self_provisioning_domains + + await self._http.post( + MgmtV1.tenant_update_path, + body=TenantBase._compose_create_update_body( + name, + id, + self_provisioning_domains, + custom_attributes, + enforce_sso, + enforce_sso_exclusions, + federated_app_ids, + disabled, + ), + ) + + async def update_settings( + self, + id: str, + self_provisioning_domains: List[str], + domains: Optional[List[str]] = None, + auth_type: Optional[TenantAuthType] = None, + session_settings_enabled: Optional[bool] = None, + refresh_token_expiration: Optional[int] = None, + refresh_token_expiration_unit: Optional[SessionExpirationUnit] = None, + session_token_expiration: Optional[int] = None, + session_token_expiration_unit: Optional[SessionExpirationUnit] = None, + stepup_token_expiration: Optional[int] = None, + stepup_token_expiration_unit: Optional[SessionExpirationUnit] = None, + enable_inactivity: Optional[bool] = None, + inactivity_time: Optional[int] = None, + inactivity_time_unit: Optional[SessionExpirationUnit] = None, + JITDisabled: Optional[bool] = None, + sso_setup_suite_settings: Optional[SSOSetupSuiteSettings] = None, + enforce_sso: Optional[bool] = None, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + ): + """ + Update an existing tenant's session settings. + + Args: + id (str): The ID of the tenant to update. + self_provisioning_domains (List[str]): Domains for self-provisioning. + domains (Optional[List[str]]): List of domains associated with the tenant. + auth_type (Optional[TenantAuthType]): Authentication type for the tenant. + session_settings_enabled (Optional[bool]): Whether session settings are enabled. + refresh_token_expiration (Optional[int]): Expiration time for refresh tokens. + refresh_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for refresh token expiration. + session_token_expiration (Optional[int]): Expiration time for session tokens. + session_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for session token expiration. + stepup_token_expiration (Optional[int]): Expiration time for step-up tokens. + stepup_token_expiration_unit (Optional[SessionExpirationUnit]): Unit for step-up token expiration. + enable_inactivity (Optional[bool]): Whether inactivity timeout is enabled. + inactivity_time (Optional[int]): Inactivity timeout duration. + inactivity_time_unit (Optional[SessionExpirationUnit]): Unit for inactivity timeout. + JITDisabled (Optional[bool]): Whether JIT is disabled. + sso_setup_suite_settings (Optional[SSOSetupSuiteSettings]): SSO Setup Suite configuration. + enforce_sso (Optional[bool]): Whether to enforce SSO for the tenant. + enforce_sso_exclusions (Optional[List[str]]): List of user IDs excluded from SSO enforcement. + federated_app_ids (Optional[List[str]]): List of federated application IDs. + + Raise: + AuthException: raised if update operation fails + """ + body: dict[str, Any] = { + "tenantId": id, + "selfProvisioningDomains": self_provisioning_domains, + "domains": domains, + "authType": auth_type, + "enabled": session_settings_enabled, + "refreshTokenExpiration": refresh_token_expiration, + "refreshTokenExpirationUnit": refresh_token_expiration_unit, + "sessionTokenExpiration": session_token_expiration, + "sessionTokenExpirationUnit": session_token_expiration_unit, + "stepupTokenExpiration": stepup_token_expiration, + "stepupTokenExpirationUnit": stepup_token_expiration_unit, + "enableInactivity": enable_inactivity, + "inactivityTime": inactivity_time, + "inactivityTimeUnit": inactivity_time_unit, + "JITDisabled": JITDisabled, + "ssoSetupSuiteSettings": (sso_setup_suite_settings.to_dict() if sso_setup_suite_settings else None), + "enforceSSO": enforce_sso, + "enforceSSOExclusions": enforce_sso_exclusions, + "federatedAppIds": federated_app_ids, + } + + body = {k: v for k, v in body.items() if v is not None} + + await self._http.post(MgmtV1.tenant_settings_path, body=body, params=None) + + async def update_default_roles( + self, + tenant_id: str, + role_names: List[str], + ) -> None: + """ + Set which project default roles apply to new users in this tenant. + + Args: + tenant_id (str): The ID of the tenant to update. + role_names (List[str]): List of role names to set as tenant default roles. + + Raise: + AuthException: raised if update operation fails + """ + await self._http.post( + MgmtV1.tenant_update_default_roles_path, + body={"id": tenant_id, "defaultRoles": role_names}, + ) + + async def delete( + self, + id: str, + cascade: bool = False, + ): + """ + Delete an existing tenant. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the tenant that's to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + await self._http.post( + MgmtV1.tenant_delete_path, + body={"id": id, "cascade": cascade}, + ) + + async def load( + self, + id: str, + ) -> dict: + """ + Load tenant by id. + + Args: + id (str): The ID of the tenant to load. + + Return value (dict): + Return dict in the format + {"id": , "name": , "selfProvisioningDomains": [], "customAttributes: {}, "createdTime": } + Containing the loaded tenant information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + MgmtV1.tenant_load_path, + params={"id": id}, + ) + return response.json() + + async def load_settings( + self, + id: str, + ) -> dict: + """ + Load tenant session settings by id. + + Args: + id (str): The ID of the tenant to load session settings for. + + Return value (dict): + Return dict in the format + { "domains":, "selfProvisioningDomains":, "authType":, + "enabled":, "refreshTokenExpiration":, "refreshTokenExpirationUnit":, + "sessionTokenExpiration":, "sessionTokenExpirationUnit":, + "stepupTokenExpiration":, "stepupTokenExpirationUnit":, + "enableInactivity":, "inactivityTime":, "inactivityTimeUnit":, + "JITDisabled":, "ssoSetupSuiteSettings": } + Containing the loaded tenant session settings. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + MgmtV1.tenant_settings_path, + params={"id": id}, + ) + return response.json() + + async def load_all( + self, + ) -> dict: + """ + Load all tenants. + + Return value (dict): + Return dict in the format + {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes: {}, "createdTime": }]} + Containing the loaded tenant information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + MgmtV1.tenant_load_all_path, + ) + return response.json() + + async def search_all( + self, + ids: Optional[List[str]] = None, + names: Optional[List[str]] = None, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + ) -> dict: + """ + Search all tenants. + + Args: + ids (List[str]): Optional list of tenant IDs to filter by + names (List[str]): Optional list of names to filter by + self_provisioning_domains (List[str]): Optional list of self provisioning domains to filter by + custom_attributes (dict): Optional search for a attribute with a given value + + Return value (dict): + Return dict in the format + {"tenants": [{"id": , "name": , "selfProvisioningDomains": [], customAttributes:{}}]} + Containing the loaded tenant information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.post( + MgmtV1.tenant_search_all_path, + body={ + "tenantIds": ids, + "tenantNames": names, + "tenantSelfProvisioningDomains": self_provisioning_domains, + "customAttributes": custom_attributes, + }, + ) + return response.json() + + async def generate_sso_configuration_link( + self, + tenant_id: str, + expire_time: Optional[int] = None, + email: Optional[str] = None, + sso_id: Optional[str] = None, + ) -> str: + """ + Generate a tenant admin self-service link for SSO configuration. + + Args: + tenant_id (str): Tenant ID to generate the link for. + expire_time (int): Optional expiration duration in seconds. For a link valid for 6 hours, use 21600. + email (str): Optional email address associated with the admin. + sso_id (str): Optional SSO identifier for the tenant. + + Return value (str): + Returns the admin SSO configuration link as a string. + + Raise: + AuthException: raised if generation operation fails + """ + body: dict[str, Any] = {"tenantId": tenant_id} + if expire_time is not None: + body["expireTime"] = expire_time + if email is not None: + body["email"] = email + if sso_id is not None: + body["ssoId"] = sso_id + + response = await self._http.post( + MgmtV1.tenant_generate_sso_configuration_link_path, + body=body, + ) + result = response.json() + return result.get("adminSSOConfigurationLink", "") diff --git a/descope/management/user.py b/descope/management/user.py index 780545917..769a07659 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -1,79 +1,18 @@ -from typing import Any, List, Optional, Union +from typing import List, Optional, Union from descope._http_base import HTTPBase from descope.common import DeliveryMethod, LoginOptions, get_method_string from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.management._user_base import CreateUserObj, UserBase, UserObj from descope.management.common import ( AssociatedTenant, MgmtV1, Sort, - associated_tenants_to_dict, sort_to_dict, ) -from descope.management.user_pwd import UserPassword -class UserObj: - def __init__( - self, - login_id: str, - email: Optional[str] = None, - phone: Optional[str] = None, - display_name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - role_names: Optional[List[str]] = None, - user_tenants: Optional[List[AssociatedTenant]] = None, - picture: Optional[str] = None, - custom_attributes: Optional[dict] = None, - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - password: Optional[UserPassword] = None, - seed: Optional[str] = None, - status: Optional[str] = None, - ): - self.login_id = login_id - self.email = email - self.phone = phone - self.display_name = display_name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - self.role_names = role_names - self.user_tenants = user_tenants - self.picture = picture - self.custom_attributes = custom_attributes - self.verified_email = verified_email - self.verified_phone = verified_phone - self.additional_login_ids = additional_login_ids - self.sso_app_ids = sso_app_ids - self.password = password - self.seed = seed - self.status = status - - -class CreateUserObj: - def __init__( - self, - email: Optional[str] = None, - phone: Optional[str] = None, - name: Optional[str] = None, - given_name: Optional[str] = None, - middle_name: Optional[str] = None, - family_name: Optional[str] = None, - ): - self.email = email - self.phone = phone - self.name = name - self.given_name = given_name - self.middle_name = middle_name - self.family_name = family_name - - -class User(HTTPBase): +class User(UserBase, HTTPBase): def create( self, login_id: str, @@ -122,7 +61,7 @@ def create( response = self._http.post( MgmtV1.user_create_path, - body=User._compose_create_body( + body=UserBase._compose_create_body( login_id, email, phone, @@ -197,7 +136,7 @@ def create_test_user( response = self._http.post( MgmtV1.test_user_create_path, - body=User._compose_create_body( + body=UserBase._compose_create_body( login_id, email, phone, @@ -262,7 +201,7 @@ def invite( response = self._http.post( MgmtV1.user_create_path, - body=User._compose_create_body( + body=UserBase._compose_create_body( login_id, email, phone, @@ -311,7 +250,7 @@ def invite_batch( response = self._http.post( MgmtV1.user_create_batch_path, - body=User._compose_create_batch_body( + body=UserBase._compose_create_batch_body( users, invite_url, send_mail, @@ -375,7 +314,7 @@ def update( response = self._http.post( MgmtV1.user_update_path, - body=User._compose_update_body( + body=UserBase._compose_update_body( login_id, email, phone, @@ -458,7 +397,7 @@ def patch( ) response = self._http.patch( MgmtV1.user_patch_path, - body=User._compose_patch_body( + body=UserBase._compose_patch_body( login_id, email, phone, @@ -517,7 +456,7 @@ def patch_batch( response = self._http.patch( MgmtV1.user_patch_batch_path, - body=User._compose_patch_batch_body(users, test), + body=UserBase._compose_patch_batch_body(users, test), ) return response.json() @@ -1840,250 +1779,3 @@ def history(self, user_ids: List[str]) -> List[dict]: body=user_ids, ) return response.json() - - @staticmethod - def _compose_create_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: List[str], - user_tenants: List[AssociatedTenant], - invite: bool, - test: bool, - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool], - verified_phone: Optional[bool], - invite_url: Optional[str], - send_mail: Optional[bool], - send_sms: Optional[bool], - additional_login_ids: Optional[List[str]], - sso_app_ids: Optional[List[str]] = None, - template_id: str = "", - locale: Optional[str] = None, - ) -> dict: - body = User._compose_update_body( - login_id=login_id, - email=email, - phone=phone, - display_name=display_name, - given_name=given_name, - middle_name=middle_name, - family_name=family_name, - role_names=role_names, - user_tenants=user_tenants, - test=test, - picture=picture, - custom_attributes=custom_attributes, - additional_login_ids=additional_login_ids, - sso_app_ids=sso_app_ids, - ) - body["invite"] = invite - if verified_email is not None: - body["verifiedEmail"] = verified_email - if verified_phone is not None: - body["verifiedPhone"] = verified_phone - if invite_url is not None: - body["inviteUrl"] = invite_url - if send_mail is not None: - body["sendMail"] = send_mail - if send_sms is not None: - body["sendSMS"] = send_sms - if template_id != "": - body["templateId"] = template_id - if locale is not None: - body["locale"] = locale - return body - - @staticmethod - def _compose_create_batch_body( - users: List[UserObj], - invite_url: Optional[str], - send_mail: Optional[bool], - send_sms: Optional[bool], - locale: Optional[str] = None, - ) -> dict: - usersBody = [] - for user in users: - role_names = [] if user.role_names is None else user.role_names - user_tenants = [] if user.user_tenants is None else user.user_tenants - sso_app_ids = [] if user.sso_app_ids is None else user.sso_app_ids - password = None if user.password is None else user.password.cleartext - hashed_password = None - if (user.password is not None) and (user.password.hashed is not None): - hashed_password = user.password.hashed.to_dict() - uBody = User._compose_update_body( - login_id=user.login_id, - email=user.email, - phone=user.phone, - display_name=user.display_name, - given_name=user.given_name, - middle_name=user.middle_name, - family_name=user.family_name, - role_names=role_names, - user_tenants=user_tenants, - picture=user.picture, - custom_attributes=user.custom_attributes, - additional_login_ids=user.additional_login_ids, - verified_email=user.verified_email, - verified_phone=user.verified_phone, - test=False, - sso_app_ids=sso_app_ids, - password=password, - hashed_password=hashed_password, - seed=user.seed, - ) - if user.status is not None: - uBody["status"] = user.status - usersBody.append(uBody) - - body = {"users": usersBody, "invite": True} - if invite_url is not None: - body["inviteUrl"] = invite_url - if send_mail is not None: - body["sendMail"] = send_mail - if send_sms is not None: - body["sendSMS"] = send_sms - if locale is not None: - body["locale"] = locale - return body - - @staticmethod - def _compose_update_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: List[str], - user_tenants: List[AssociatedTenant], - test: bool, - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool] = None, - verified_phone: Optional[bool] = None, - additional_login_ids: Optional[List[str]] = None, - sso_app_ids: Optional[List[str]] = None, - password: Optional[str] = None, - hashed_password: Optional[dict] = None, - seed: Optional[str] = None, - ) -> dict: - res = { - "loginId": login_id, - "email": email, - "phone": phone, - "displayName": display_name, - "roleNames": role_names, - "userTenants": associated_tenants_to_dict(user_tenants), - "test": test, - "picture": picture, - "customAttributes": custom_attributes, - "additionalLoginIds": additional_login_ids, - "ssoAppIDs": sso_app_ids, - } - if verified_email is not None: - res["verifiedEmail"] = verified_email - if given_name is not None: - res["givenName"] = given_name - if middle_name is not None: - res["middleName"] = middle_name - if family_name is not None: - res["familyName"] = family_name - if verified_phone is not None: - res["verifiedPhone"] = verified_phone - if password is not None: - res["password"] = password - if hashed_password is not None: - res["hashedPassword"] = hashed_password - if seed is not None: - res["seed"] = seed - return res - - @staticmethod - def _compose_patch_body( - login_id: str, - email: Optional[str], - phone: Optional[str], - display_name: Optional[str], - given_name: Optional[str], - middle_name: Optional[str], - family_name: Optional[str], - role_names: Optional[List[str]], - user_tenants: Optional[List[AssociatedTenant]], - picture: Optional[str], - custom_attributes: Optional[dict], - verified_email: Optional[bool], - verified_phone: Optional[bool], - sso_app_ids: Optional[List[str]], - status: Optional[str], - test: bool = False, - ) -> dict: - res: dict[str, Any] = { - "loginId": login_id, - } - if email is not None: - res["email"] = email - if phone is not None: - res["phone"] = phone - if display_name is not None: - res["displayName"] = display_name - if given_name is not None: - res["givenName"] = given_name - if middle_name is not None: - res["middleName"] = middle_name - if family_name is not None: - res["familyName"] = family_name - if role_names is not None: - res["roleNames"] = role_names - if user_tenants is not None: - res["userTenants"] = associated_tenants_to_dict(user_tenants) - if picture is not None: - res["picture"] = picture - if custom_attributes is not None: - res["customAttributes"] = custom_attributes - if verified_email is not None: - res["verifiedEmail"] = verified_email - if verified_phone is not None: - res["verifiedPhone"] = verified_phone - if sso_app_ids is not None: - res["ssoAppIds"] = sso_app_ids - if status is not None: - res["status"] = status - if test: - res["test"] = test - return res - - @staticmethod - def _compose_patch_batch_body( - users: List[UserObj], - test: bool = False, - ) -> dict: - users_body = [] - for user in users: - user_body = User._compose_patch_body( - login_id=user.login_id, - email=user.email, - phone=user.phone, - display_name=user.display_name, - given_name=user.given_name, - middle_name=user.middle_name, - family_name=user.family_name, - role_names=user.role_names, - user_tenants=user.user_tenants, - picture=user.picture, - custom_attributes=user.custom_attributes, - verified_email=user.verified_email, - verified_phone=user.verified_phone, - sso_app_ids=user.sso_app_ids, - status=user.status, - test=test, - ) - users_body.append(user_body) - - return {"users": users_body} diff --git a/descope/management/user_async.py b/descope/management/user_async.py new file mode 100644 index 000000000..c611d6b31 --- /dev/null +++ b/descope/management/user_async.py @@ -0,0 +1,1786 @@ +from __future__ import annotations + +from typing import Any, List, Optional, Union + +from descope._http_base import AsyncHTTPBase +from descope.common import DeliveryMethod, LoginOptions, get_method_string +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.management._user_base import CreateUserObj, UserBase, UserObj +from descope.management.common import ( + AssociatedTenant, + MgmtV1, + Sort, + sort_to_dict, +) +from descope.management.user_pwd import UserPassword + + +class UserAsync(UserBase, AsyncHTTPBase): + """Async counterpart of User — all HTTP calls are coroutines.""" + + async def create( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + invite_url: Optional[str] = None, + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + ) -> dict: + """ + Create a new user. Users can have any number of optional fields, including email, phone number and authorization. + + Args: + login_id (str): user login ID. + email (str): Optional user email address. + phone (str): Optional user phone number. + display_name (str): Optional user display name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles. + user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`. + picture (str): Optional url for user picture + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the created user information. + + Raise: + AuthException: raised if create operation fails + """ + role_names = [] if role_names is None else role_names + user_tenants = [] if user_tenants is None else user_tenants + + response = await self._http.post( + MgmtV1.user_create_path, + body=UserBase._compose_create_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + False, + False, + picture, + custom_attributes, + verified_email, + verified_phone, + invite_url, + None, + None, + additional_login_ids, + sso_app_ids, + ), + ) + return response.json() + + async def create_test_user( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + invite_url: Optional[str] = None, + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + ) -> dict: + """ + Create a new test user. + The login_id is required and will determine what the user will use to sign in. + Make sure the login id is unique for test. All other fields are optional. + + Args: + login_id (str): user login ID. + email (str): Optional user email address. + phone (str): Optional user phone number. + display_name (str): Optional user display name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles. + user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`. + picture (str): Optional url for user picture + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the created test user information. + + Raise: + AuthException: raised if create operation fails + """ + role_names = [] if role_names is None else role_names + user_tenants = [] if user_tenants is None else user_tenants + + response = await self._http.post( + MgmtV1.test_user_create_path, + body=UserBase._compose_create_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + False, + True, + picture, + custom_attributes, + verified_email, + verified_phone, + invite_url, + None, + None, + additional_login_ids, + sso_app_ids, + ), + ) + return response.json() + + async def invite( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + invite_url: Optional[str] = None, + send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings + send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + template_id: str = "", + test: bool = False, + locale: Optional[str] = None, # locale for the invite message + ) -> dict: + """ + Create a new user and invite them via an email / text message. + + Functions exactly the same as the `create` function with the additional invitation + behavior. See the documentation above for the general creation behavior. + + IMPORTANT: Since the invitation is sent by email / phone, make sure either + the email / phone is explicitly set, or the login_id itself is an email address / phone number. + You must configure the invitation URL in the Descope console prior to + calling the method. + """ + role_names = [] if role_names is None else role_names + user_tenants = [] if user_tenants is None else user_tenants + + response = await self._http.post( + MgmtV1.user_create_path, + body=UserBase._compose_create_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + True, + test, + picture, + custom_attributes, + verified_email, + verified_phone, + invite_url, + send_mail, + send_sms, + additional_login_ids, + sso_app_ids, + template_id, + locale, + ), + ) + return response.json() + + async def invite_batch( + self, + users: List[UserObj], + invite_url: Optional[str] = None, + send_mail: Optional[bool] = None, # send invite via mail, default is according to project settings + send_sms: Optional[bool] = None, # send invite via text message, default is according to project settings + locale: Optional[str] = None, # locale for the invite message + ) -> dict: + """ + Create users in batch and invite them via an email / text message. + + Functions exactly the same as the `create` function with the additional invitation + behavior. See the documentation above for the general creation behavior. + + IMPORTANT: Since the invitation is sent by email / phone, make sure either + the email / phone is explicitly set, or the login_id itself is an email address / phone number. + You must configure the invitation URL in the Descope console prior to + calling the method. + """ + + response = await self._http.post( + MgmtV1.user_create_batch_path, + body=UserBase._compose_create_batch_body( + users, + invite_url, + send_mail, + send_sms, + locale, + ), + ) + return response.json() + + async def update( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + additional_login_ids: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + test: bool = False, + ) -> dict: + """ + Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing user. Empty fields will override populated fields. Use carefully. + Use `patch` for partial updates instead. + + Args: + login_id (str): The login ID of the user to update. + email (str): Optional user email address. + phone (str): Optional user phone number. + display_name (str): Optional user display name. + given_name (str): Optional user given name. + middle_name (str): Optional user middle name. + family_name (str): Optional user family name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles. + user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`. + picture (str): Optional url for user picture + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + test (bool, optional): Set to True to update a test user. Defaults to False. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if update operation fails + """ + role_names = [] if role_names is None else role_names + user_tenants = [] if user_tenants is None else user_tenants + + response = await self._http.post( + MgmtV1.user_update_path, + body=UserBase._compose_update_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + test, + picture, + custom_attributes, + verified_email, + verified_phone, + additional_login_ids, + sso_app_ids, + None, + ), + ) + return response.json() + + async def patch( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + sso_app_ids: Optional[List[str]] = None, + status: Optional[str] = None, + test: bool = False, + ) -> dict: + """ + Patches an existing user with the given various fields. Only the given fields will be used to update the user. + + Args: + login_id (str): The login ID of the user to update. + email (str): Optional user email address. + phone (str): Optional user phone number. + display_name (str): Optional user display name. + given_name (str): Optional user given name. + middle_name (str): Optional user middle name. + family_name (str): Optional user family name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles. + user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`. + picture (str): Optional url for user picture + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". + test (bool, optional): Set to True to update a test user. Defaults to False. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the patched user information. + + Raise: + AuthException: raised if patch operation fails + """ + if status is not None and status not in [ + "enabled", + "disabled", + "invited", + "expired", + ]: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Invalid status value: {status}. Must be one of: enabled, disabled, invited, expired", + ) + response = await self._http.patch( + MgmtV1.user_patch_path, + body=UserBase._compose_patch_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + picture, + custom_attributes, + verified_email, + verified_phone, + sso_app_ids, + status, + test, + ), + ) + return response.json() + + async def patch_batch( + self, + users: List[UserObj], + test: bool = False, + ) -> dict: + """ + Patch users in batch. Only the provided fields will be updated for each user. + + Args: + users (List[UserObj]): A list of UserObj instances representing users to be patched. + Each UserObj should have a login_id and the fields to be updated. + test (bool, optional): Set to True to patch test users. Defaults to False. + + Return value (dict): + Return dict in the format + {"patchedUsers": [...], "failedUsers": [...]} + "patchedUsers" contains successfully patched users, + "failedUsers" contains users that failed to be patched with error details. + + Raise: + AuthException: raised if patch batch operation fails + """ + # Validate status fields for all users + for user in users: + if user.status is not None and user.status not in [ + "enabled", + "disabled", + "invited", + "expired", + ]: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Invalid status value: {user.status} for user {user.login_id}. Must be one of: enabled, disabled, invited, expired", + ) + + response = await self._http.patch( + MgmtV1.user_patch_batch_path, + body=UserBase._compose_patch_batch_body(users, test), + ) + return response.json() + + async def delete( + self, + login_id: str, + ): + """ + Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. + + Args: + login_id (str): The login ID of the user to be deleted. + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.post( + MgmtV1.user_delete_path, + body={"loginId": login_id}, + ) + + async def delete_by_user_id( + self, + user_id: str, + ): + """ + Delete an existing user by user ID. IMPORTANT: This action is irreversible. Use carefully. + + Args: + user_id (str): The user ID from the user's JWT. + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.post( + MgmtV1.user_delete_path, + body={"userId": user_id}, + ) + + async def delete_all_test_users( + self, + ): + """ + Delete all test users in the project. IMPORTANT: This action is irreversible. Use carefully. + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.delete( + MgmtV1.user_delete_all_test_users_path, + ) + + async def load( + self, + login_id: str, + ) -> dict: + """ + Load an existing user. + + Args: + login_id (str): The login ID of the user to be loaded. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the loaded user information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + uri=MgmtV1.user_load_path, + params={"loginId": login_id}, + ) + return response.json() + + async def load_by_user_id( + self, + user_id: str, + ) -> dict: + """ + Load an existing user by user ID. + The user ID can be found on the user's JWT. + + Args: + user_id (str): The user ID from the user's JWT. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the loaded user information. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get( + uri=MgmtV1.user_load_path, + params={"userId": user_id}, + ) + return response.json() + + async def logout_user( + self, + login_id: str, + ): + """ + Logout a user from all devices. + + Args: + login_id (str): The login ID of the user to be logged out. + + Raise: + AuthException: raised if logout operation fails + """ + await self._http.post( + MgmtV1.user_logout_path, + body={"loginId": login_id}, + ) + + async def logout_user_by_user_id( + self, + user_id: str, + ): + """ + Logout a user from all devices. + + Args: + user_id (str): The login ID of the user to be logged out. + + Raise: + AuthException: raised if logout operation fails + """ + await self._http.post( + MgmtV1.user_logout_path, + body={"userId": user_id}, + ) + + async def load_users( + self, + user_ids: List[str], + include_invalid_users: Optional[bool] = None, + ) -> dict: + """ + Load users by their user IDs. + + Args: + user_ids (List[str]): Optional list of user IDs to filter by + include_invalid_users (bool): Optional flag to include invalid users in the response + + Return value (dict): + Return dict in the format + {"users": []} + "users" contains a list of all of the found users and their information + + Raise: + AuthException: raised if search operation fails + """ + if user_ids is None or len(user_ids) == 0: + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + "At least one user id needs to be supplied", + ) + + body: dict[str, Union[List[str], bool]] = { + "userIds": user_ids, + } + + if include_invalid_users is not None: + body["includeInvalidUsers"] = include_invalid_users + + response = await self._http.post( + MgmtV1.users_load_path, + body=body, + ) + return response.json() + + async def search_all( + self, + tenant_ids: Optional[List[str]] = None, + role_names: Optional[List[str]] = None, + limit: int = 0, + page: int = 0, + test_users_only: bool = False, + with_test_user: bool = False, + custom_attributes: Optional[dict] = None, + statuses: Optional[List[str]] = None, + emails: Optional[List[str]] = None, + phones: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + sort: Optional[List[Sort]] = None, + text: Optional[str] = None, + login_ids: Optional[List[str]] = None, + from_created_time: Optional[int] = None, + to_created_time: Optional[int] = None, + from_modified_time: Optional[int] = None, + to_modified_time: Optional[int] = None, + user_ids: Optional[List[str]] = None, + tenant_role_ids: Optional[dict] = None, + tenant_role_names: Optional[dict] = None, + ) -> dict: + """ + Search all users. + + Args: + tenant_ids (List[str]): Optional list of tenant IDs to filter by + role_names (List[str]): Optional list of role names to filter by + limit (int): Optional limit of the number of users returned. Leave empty for default. + page (int): Optional pagination control. Pages start at 0 and must be non-negative. + test_users_only (bool): Optional filter only test users. + with_test_user (bool): Optional include test users in search. + custom_attributes (dict): Optional search for a attribute with a given value + statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") + emails (List[str]): Optional list of emails to search for + phones (List[str]): Optional list of phones to search for + sso_app_ids (List[str]): Optional list of SSO application IDs to filter by + text (str): Optional string, allows free text search among all user's attributes. + login_ids (List[str]): Optional list of login ids + sort (List[Sort]): Optional List[dict], allows to sort by fields. + from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) + to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) + from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) + to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) + user_ids (List[str]): Optional list of user IDs to filter by + tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. + Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). + tenant_role_names (dict): Optional mapping of tenant ID to list of role names. + Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). + + Return value (dict): + Return dict in the format + {"users": []} + "users" contains a list of all of the found users and their information + + Raise: + AuthException: raised if search operation fails + """ + tenant_ids = [] if tenant_ids is None else tenant_ids + role_names = [] if role_names is None else role_names + + if limit < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") + + if page < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + body = { + "tenantIds": tenant_ids, + "roleNames": role_names, + "limit": limit, + "page": page, + "testUsersOnly": test_users_only, + "withTestUser": with_test_user, + } + if statuses is not None: + body["statuses"] = statuses + + if emails is not None: + body["emails"] = emails + + if phones is not None: + body["phones"] = phones + + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + + if sso_app_ids is not None: + body["ssoAppIds"] = sso_app_ids + + if login_ids is not None: + body["loginIds"] = login_ids + + if user_ids is not None: + body["userIds"] = user_ids + + if text is not None: + body["text"] = text + + if sort is not None: + body["sort"] = sort_to_dict(sort) + + if from_created_time is not None: + body["fromCreatedTime"] = from_created_time + if to_created_time is not None: + body["toCreatedTime"] = to_created_time + if from_modified_time is not None: + body["fromModifiedTime"] = from_modified_time + if to_modified_time is not None: + body["toModifiedTime"] = to_modified_time + + if tenant_role_ids is not None: + body["tenantRoleIds"] = tenant_role_ids + if tenant_role_names is not None: + body["tenantRoleNames"] = tenant_role_names + + response = await self._http.post( + MgmtV1.users_search_path, + body=body, + ) + return response.json() + + async def search_all_test_users( + self, + tenant_ids: Optional[List[str]] = None, + role_names: Optional[List[str]] = None, + limit: int = 0, + page: int = 0, + custom_attributes: Optional[dict] = None, + statuses: Optional[List[str]] = None, + emails: Optional[List[str]] = None, + phones: Optional[List[str]] = None, + sso_app_ids: Optional[List[str]] = None, + sort: Optional[List[Sort]] = None, + text: Optional[str] = None, + login_ids: Optional[List[str]] = None, + from_created_time: Optional[int] = None, + to_created_time: Optional[int] = None, + from_modified_time: Optional[int] = None, + to_modified_time: Optional[int] = None, + tenant_role_ids: Optional[dict] = None, + tenant_role_names: Optional[dict] = None, + ) -> dict: + """ + Search all test users. + + Args: + tenant_ids (List[str]): Optional list of tenant IDs to filter by + role_names (List[str]): Optional list of role names to filter by + limit (int): Optional limit of the number of users returned. Leave empty for default. + page (int): Optional pagination control. Pages start at 0 and must be non-negative. + custom_attributes (dict): Optional search for a attribute with a given value + statuses (List[str]): Optional list of statuses to search for ("enabled", "disabled", "invited", "expired") + emails (List[str]): Optional list of emails to search for + phones (List[str]): Optional list of phones to search for + sso_app_ids (List[str]): Optional list of SSO application IDs to filter by + text (str): Optional string, allows free text search among all user's attributes. + login_ids (List[str]): Optional list of login ids + sort (List[Sort]): Optional List[dict], allows to sort by fields. + from_created_time (int): Optional int, only include users who were created on or after this time (in Unix epoch milliseconds) + to_created_time (int): Optional int, only include users who were created on or before this time (in Unix epoch milliseconds) + from_modified_time (int): Optional int, only include users whose last modification/update occurred on or after this time (in Unix epoch milliseconds) + to_modified_time (int): Optional int, only include users whose last modification/update occurred on or before this time (in Unix epoch milliseconds) + tenant_role_ids (dict): Optional mapping of tenant ID to list of role IDs. + Dict value is in the form of {"tenant_id": {"values":["role_id1", "role_id2"], "and": True}} if you want to match all roles (AND) or any role (OR). + tenant_role_names (dict): Optional mapping of tenant ID to list of role names. + Dict value is in the form of {"tenant_id": {"values":["role_name1", "role_name2"], "and": True}} if you want to match all roles (AND) or any role (OR). + + Return value (dict): + Return dict in the format + {"users": []} + "users" contains a list of all of the found users and their information + + Raise: + AuthException: raised if search operation fails + """ + tenant_ids = [] if tenant_ids is None else tenant_ids + role_names = [] if role_names is None else role_names + + if limit < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") + + if page < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + body = { + "tenantIds": tenant_ids, + "roleNames": role_names, + "limit": limit, + "page": page, + "testUsersOnly": True, + "withTestUser": True, + } + if statuses is not None: + body["statuses"] = statuses + + if emails is not None: + body["emails"] = emails + + if phones is not None: + body["phones"] = phones + + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + + if sso_app_ids is not None: + body["ssoAppIds"] = sso_app_ids + + if login_ids is not None: + body["loginIds"] = login_ids + + if text is not None: + body["text"] = text + + if sort is not None: + body["sort"] = sort_to_dict(sort) + + if from_created_time is not None: + body["fromCreatedTime"] = from_created_time + if to_created_time is not None: + body["toCreatedTime"] = to_created_time + if from_modified_time is not None: + body["fromModifiedTime"] = from_modified_time + if to_modified_time is not None: + body["toModifiedTime"] = to_modified_time + + if tenant_role_ids is not None: + body["tenantRoleIds"] = tenant_role_ids + if tenant_role_names is not None: + body["tenantRoleNames"] = tenant_role_names + + response = await self._http.post( + MgmtV1.test_users_search_path, + body=body, + ) + return response.json() + + async def get_provider_token( + self, + login_id: str, + provider: str, + withRefreshToken: Optional[bool] = False, + forceRefresh: Optional[bool] = False, + ) -> dict: + """ + Get the provider token for the given login ID. + Only users that sign-in using social providers will have token. + Note: The 'Manage tokens from provider' setting must be enabled. + + Args: + login_id (str): The login ID of the user. + provider (str): The provider name (google, facebook, etc'). + withRefreshToken (bool): Optional, set to true to get also the refresh token. + forceRefresh (bool): Optional, set to true to force refresh the token. + + Return value (dict): + Return dict in the format + {"provider": "", "providerUserId": "", "accessToken": "", "expiration": "", "scopes": "[]"} + Containing the provider token of the given user and provider. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.get( + MgmtV1.user_get_provider_token, + params={ + "loginId": login_id, + "provider": provider, + "withRefreshToken": withRefreshToken, + "forceRefresh": forceRefresh, + }, + ) + return response.json() + + async def activate( + self, + login_id: str, + ) -> dict: + """ + Activate an existing user. + + Args: + login_id (str): The login ID of the user to be activated. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if activate operation fails + """ + response = await self._http.post( + MgmtV1.user_update_status_path, + body={"loginId": login_id, "status": "enabled"}, + ) + return response.json() + + async def deactivate( + self, + login_id: str, + ) -> dict: + """ + Deactivate an existing user. + + Args: + login_id (str): The login ID of the user to be deactivated. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if deactivate operation fails + """ + response = await self._http.post( + MgmtV1.user_update_status_path, + body={"loginId": login_id, "status": "disabled"}, + ) + return response.json() + + async def update_login_id( + self, + login_id: str, + new_login_id: Optional[str] = None, + ) -> dict: + """ + Update login id of user, leave new login empty to remove the ID. + A user must have at least one login ID. Trying to remove the last one will fail. + + Args: + login_id (str): The login ID of the user to update. + new_login_id (str): New login ID to set for the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + response = await self._http.post( + MgmtV1.user_update_login_id_path, + body={"loginId": login_id, "newLoginId": new_login_id}, + ) + return response.json() + + async def update_email( + self, + login_id: str, + email: Optional[str] = None, + verified: Optional[bool] = None, + fail_on_conflict: Optional[bool] = None, + ) -> dict: + """ + Update the email address for an existing user. + + Args: + login_id (str): The login ID of the user to update the email for. + email (str): The user email address. Leave empty to remove. + verified (bool): Set to true for the user to be able to login with the email address. + fail_on_conflict (bool): Set to true to raise an error if the email is used as a login id and already exists. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + response = await self._http.post( + MgmtV1.user_update_email_path, + body={ + "loginId": login_id, + "email": email, + "verified": verified, + "failOnConflict": fail_on_conflict, + }, + ) + return response.json() + + async def update_phone( + self, + login_id: str, + phone: Optional[str] = None, + verified: Optional[bool] = None, + fail_on_conflict: Optional[bool] = None, + ) -> dict: + """ + Update the phone number for an existing user. + + Args: + login_id (str): The login ID of the user to update the phone for. + phone (str): The user phone number. Leave empty to remove. + verified (bool): Set to true for the user to be able to login with the phone number. + fail_on_conflict (bool): Set to true to raise an error if the phone is used as a login id and already exists. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + response = await self._http.post( + MgmtV1.user_update_phone_path, + body={ + "loginId": login_id, + "phone": phone, + "verified": verified, + "failOnConflict": fail_on_conflict, + }, + ) + return response.json() + + async def update_display_name( + self, + login_id: str, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + ) -> dict: + """ + Update the display name for an existing user. + + Args: + login_id (str): The login ID of the user to update. + display_name (str): Optional user display name. Leave empty to remove. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + bdy = {"loginId": login_id} + if display_name is not None: + bdy["displayName"] = display_name + if given_name is not None: + bdy["givenName"] = given_name + if middle_name is not None: + bdy["middleName"] = middle_name + if family_name is not None: + bdy["familyName"] = family_name + response = await self._http.post( + MgmtV1.user_update_name_path, + body=bdy, + ) + return response.json() + + async def update_picture( + self, + login_id: str, + picture: Optional[str] = None, + ) -> dict: + """ + Update the picture for an existing user. + + Args: + login_id (str): The login ID of the user to update. + picture (str): Optional url to user avatar. Leave empty to remove. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + response = await self._http.post( + MgmtV1.user_update_picture_path, + body={"loginId": login_id, "picture": picture}, + ) + return response.json() + + async def update_custom_attribute(self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool]) -> dict: + """ + Update a custom attribute of an existing user. + + Args: + login_id (str): The login ID of the user to update. + attribute_key (str): The custom attribute that needs to be updated, this attribute needs to exists in Descope console app + attribute_val: The value to be updated + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the update operation fails + """ + response = await self._http.post( + MgmtV1.user_update_custom_attribute_path, + body={ + "loginId": login_id, + "attributeKey": attribute_key, + "attributeValue": attribute_val, + }, + ) + return response.json() + + async def set_roles( + self, + login_id: str, + role_names: List[str], + ) -> dict: + """ + Set roles to a user without tenant association. Use set_tenant_roles + for users that are part of a multi-tenant project. + + Args: + login_id (str): The login ID of the user to update. + role_names (List[str]): A list of roles to set to a user without tenant association. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_set_role_path, + body={"loginId": login_id, "roleNames": role_names}, + ) + return response.json() + + async def add_roles( + self, + login_id: str, + role_names: List[str], + ) -> dict: + """ + Add roles to a user without tenant association. Use add_tenant_roles + for users that are part of a multi-tenant project. + + Args: + login_id (str): The login ID of the user to update. + role_names (List[str]): A list of roles to add to a user without tenant association. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_add_role_path, + body={"loginId": login_id, "roleNames": role_names}, + ) + return response.json() + + async def remove_roles( + self, + login_id: str, + role_names: List[str], + ) -> dict: + """ + Remove roles from a user without tenant association. Use remove_tenant_roles + for users that are part of a multi-tenant project. + + Args: + login_id (str): The login ID of the user to update. + role_names (List[str]): A list of roles to remove from a user without tenant association. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_remove_role_path, + body={"loginId": login_id, "roleNames": role_names}, + ) + return response.json() + + async def set_sso_apps( + self, + login_id: str, + sso_app_ids: List[str], + ) -> dict: + """ + Set SSO applications association to a user. + + Args: + login_id (str): The login ID of the user to update. + sso_app_ids (List[str]): A list of sso applications ids for associate with a user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_set_sso_apps, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, + ) + return response.json() + + async def add_sso_apps( + self, + login_id: str, + sso_app_ids: List[str], + ) -> dict: + """ + Add SSO applications association to a user. + + Args: + login_id (str): The login ID of the user to update. + sso_app_ids (List[str]): A list of sso applications ids for associate with a user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_add_sso_apps, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, + ) + return response.json() + + async def remove_sso_apps( + self, + login_id: str, + sso_app_ids: List[str], + ) -> dict: + """ + Remove SSO applications association from a user. + + Args: + login_id (str): The login ID of the user to update. + sso_app_ids (List[str]): A list of sso applications ids to remove association from a user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_remove_sso_apps, + body={"loginId": login_id, "ssoAppIds": sso_app_ids}, + ) + return response.json() + + async def add_tenant( + self, + login_id: str, + tenant_id: str, + ) -> dict: + """ + Add a tenant association to an existing user. + + Args: + login_id (str): The login ID of the user to update. + tenant_id (str): The ID of the tenant to add to the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_add_tenant_path, + body={"loginId": login_id, "tenantId": tenant_id}, + ) + return response.json() + + async def remove_tenant( + self, + login_id: str, + tenant_id: str, + ) -> dict: + """ + Remove a tenant association from an existing user. + + Args: + login_id (str): The login ID of the user to update. + tenant_id (str): The ID of the tenant to add to the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_remove_tenant_path, + body={"loginId": login_id, "tenantId": tenant_id}, + ) + return response.json() + + async def set_tenant_roles( + self, + login_id: str, + tenant_id: str, + role_names: List[str], + ) -> dict: + """ + Set roles to a user in a specific tenant. + + Args: + login_id (str): The login ID of the user to update. + tenant_id (str): The ID of the user's tenant. + role_names (List[str]): A list of roles to set on the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_set_role_path, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, + ) + return response.json() + + async def add_tenant_roles( + self, + login_id: str, + tenant_id: str, + role_names: List[str], + ) -> dict: + """ + Add roles to a user in a specific tenant. + + Args: + login_id (str): The login ID of the user to update. + tenant_id (str): The ID of the user's tenant. + role_names (List[str]): A list of roles to add to the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_add_role_path, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, + ) + return response.json() + + async def remove_tenant_roles( + self, + login_id: str, + tenant_id: str, + role_names: List[str], + ) -> dict: + """ + Remove roles from a user in a specific tenant. + + Args: + login_id (str): The login ID of the user to update. + tenant_id (str): The ID of the user's tenant. + role_names (List[str]): A list of roles to remove from the user. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_remove_role_path, + body={"loginId": login_id, "tenantId": tenant_id, "roleNames": role_names}, + ) + return response.json() + + async def set_temporary_password( + self, + login_id: str, + password: str, + ) -> None: + """ + Set the temporary password for the given login ID. + Note: The password will automatically be set as expired. + The user will not be able to log-in with this password, and will be required to replace it on next login. + See also: expire_password + + Args: + login_id (str): The login ID of the user to set the password to. + password (str): The new password to set to the user. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_set_temporary_password_path, + body={ + "loginId": login_id, + "password": password, + "setActive": False, + }, + ) + return + + async def set_active_password( + self, + login_id: str, + password: str, + ) -> None: + """ + Set the password for the given login ID. + + Args: + login_id (str): The login ID of the user to set the password to. + password (str): The new password to set to the user. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_set_active_password_path, + body={ + "loginId": login_id, + "password": password, + "setActive": True, + }, + ) + return + + # Deprecated (use set_temporary_password instead) + async def set_password( + self, + login_id: str, + password: str, + ) -> None: + """ + Set the password for the given login ID. + Note: The password will automatically be set as expired. + The user will not be able to log-in with this password, and will be required to replace it on next login. + See also: expire_password + + Args: + login_id (str): The login ID of the user to set the password to. + password (str): The new password to set to the user. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_set_password_path, + body={ + "loginId": login_id, + "password": password, + }, + ) + return + + async def expire_password( + self, + login_id: str, + ) -> None: + """ + Expires the password for the given login ID. + Note: user sign-in with an expired password, the user will get an error with code. + Use the `password.send_reset` or `password.replace` methods to reset/replace the password. + + Args: + login_id (str): The login ID of the user to expire the password to. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_expire_password_path, + body={"loginId": login_id}, + ) + return + + async def remove_all_passkeys( + self, + login_id: str, + ) -> None: + """ + Removes all registered passkeys (WebAuthn devices) for the user with the given login ID. + Note: The user might not be able to login anymore if they have no other authentication + methods or a verified email/phone. + + Args: + login_id (str): The login ID of the user to remove passkeys for. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_remove_all_passkeys_path, + body={"loginId": login_id}, + ) + return + + async def remove_totp_seed( + self, + login_id: str, + ) -> None: + """ + Removes TOTP seed for the user with the given login ID. + Note: The user might not be able to login anymore if they have no other authentication + methods or a verified email/phone. + + Args: + login_id (str): The login ID of the user to remove totp seed for. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_remove_totp_seed_path, + body={"loginId": login_id}, + ) + return + + async def generate_otp_for_test_user( + self, + method: DeliveryMethod, + login_id: str, + login_options: Optional[LoginOptions] = None, + ) -> dict: + """ + Generate OTP for the given login ID of a test user. + This is useful when running tests and don't want to use 3rd party messaging services. + + Args: + method (DeliveryMethod): The method to use for "delivering" the OTP verification code to the user, for example + EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED + login_id (str): The login ID of the test user being validated. + login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. + + Return value (dict): + Return dict in the format + {"code": "", "loginId": ""} + Containing the code for the login (exactly as it sent via Email or Phone messaging). + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_generate_otp_for_test_path, + body={ + "loginId": login_id, + "deliveryMethod": get_method_string(method), + "loginOptions": login_options.__dict__ if login_options else {}, + }, + ) + return response.json() + + async def generate_magic_link_for_test_user( + self, + method: DeliveryMethod, + login_id: str, + uri: str, + login_options: Optional[LoginOptions] = None, + ) -> dict: + """ + Generate Magic Link for the given login ID of a test user. + This is useful when running tests and don't want to use 3rd party messaging services. + + Args: + method (DeliveryMethod): The method to use for "delivering" the verification magic link to the user, for example + EMAIL, SMS, VOICE, WHATSAPP or EMBEDDED + login_id (str): The login ID of the test user being validated. + uri (str): Optional redirect uri which will be used instead of any global configuration. + login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. + + Return value (dict): + Return dict in the format + {"link": "", "loginId": ""} + Containing the magic link for the login (exactly as it sent via Email or Phone messaging). + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_generate_magic_link_for_test_path, + body={ + "loginId": login_id, + "deliveryMethod": get_method_string(method), + "URI": uri, + "loginOptions": login_options.__dict__ if login_options else {}, + }, + ) + return response.json() + + async def generate_enchanted_link_for_test_user( + self, + login_id: str, + uri: str, + login_options: Optional[LoginOptions] = None, + ) -> dict: + """ + Generate Enchanted Link for the given login ID of a test user. + This is useful when running tests and don't want to use 3rd party messaging services. + + Args: + login_id (str): The login ID of the test user being validated. + uri (str): Optional redirect uri which will be used instead of any global configuration. + login_options (LoginOptions): optional, can be provided to set custom claims to the generated jwt. + + Return value (dict): + Return dict in the format + {"link": "", "loginId": "", "pendingRef": ""} + Containing the enchanted link for the login (exactly as it sent via Email or Phone messaging) and pendingRef. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_generate_enchanted_link_for_test_path, + body={ + "loginId": login_id, + "URI": uri, + "loginOptions": login_options.__dict__ if login_options else {}, + }, + ) + return response.json() + + async def generate_embedded_link(self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0) -> str: + """ + Generate Embedded Link for the given user login ID. + The return value is a token that can be verified via magic link, or using flows + + Args: + login_id (str): The login ID of the user to authenticate with. + custom_claims (dict): Additional claims to place on the jwt after verification + + Return value (str): + Return the token to be used in verification process + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_generate_embedded_link_path, + body={ + "loginId": login_id, + "customClaims": custom_claims, + "timeout": timeout, + }, + ) + return response.json()["token"] + + async def generate_sign_up_embedded_link( + self, + login_id: str, + user: Optional[CreateUserObj] = None, + email_verified: bool = False, + phone_verified: bool = False, + login_options: Optional[LoginOptions] = None, + timeout: int = 0, + ) -> str: + """ + Generate sign up Embedded Link for the given user login ID. + The return value is a token that can be verified via magic link, or using flows + + Args: + login_id (str): The login ID of the user to authenticate with. + user (CreateUserObj): Optional user object to create the user with + email_verified (bool): Optional, set to true if the email is verified + phone_verified (bool): Optional, set to true if the phone is verified + login_options (LoginOptions): Optional login options to customize the link + timeout (int): Optional, the timeout in seconds for the link to be valid + + Return value (str): + Return the token to be used in verification process + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_generate_sign_up_embedded_link_path, + body={ + "loginId": login_id, + "user": user.__dict__ if user else {}, + "loginOptions": login_options.__dict__ if login_options else {}, + "emailVerified": email_verified, + "phoneVerified": phone_verified, + "timeout": timeout, + }, + ) + return response.json()["token"] + + async def history(self, user_ids: List[str]) -> List[dict]: + """ + Retrieve users' authentication history, by the given user's ids. + + Args: + login_ids (List[str]): List of Users' IDs. + + Return value (List[dict]): + Return List in the format + [ + { + "userId": "User's ID", + "loginTime": "User'sLogin time", + "city": "User's city", + "country": "User's country", + "ip": User's IP + } + ] + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_history_path, + body=user_ids, + ) + return response.json() diff --git a/descope/mgmt_async.py b/descope/mgmt_async.py new file mode 100644 index 000000000..9f445c02a --- /dev/null +++ b/descope/mgmt_async.py @@ -0,0 +1,162 @@ +from typing import Optional + +from descope.auth import Auth +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.http_client_async import HTTPClientAsync +from descope.management.access_key_async import AccessKeyAsync +from descope.management.audit_async import AuditAsync +from descope.management.authz_async import AuthzAsync +from descope.management.descoper_async import DescoperAsync +from descope.management.fga_async import FGAAsync +from descope.management.flow_async import FlowAsync +from descope.management.group_async import GroupAsync +from descope.management.jwt_async import JWTAsync +from descope.management.license_async import LicenseAsync +from descope.management.management_key_async import ManagementKeyAsync +from descope.management.outbound_application_async import ( + OutboundApplicationAsync, + OutboundApplicationByTokenAsync, +) +from descope.management.permission_async import PermissionAsync +from descope.management.project_async import ProjectAsync +from descope.management.role_async import RoleAsync +from descope.management.sso_application_async import SSOApplicationAsync +from descope.management.sso_settings_async import SSOSettingsAsync + +# Import management modules after adapter to avoid circularities +from descope.management.tenant_async import TenantAsync +from descope.management.user_async import UserAsync + + +class MGMTAsync: + _http: HTTPClientAsync + + def __init__(self, http_client: HTTPClientAsync, auth: Auth, fga_cache_url: Optional[str] = None): + """Create an async management API facade. + + Args: + http_client: Async HTTP client to use for all management HTTP calls. + """ + self._http = http_client + self._access_key = AccessKeyAsync(http_client) + self._audit = AuditAsync(http_client) + self._authz = AuthzAsync(http_client, fga_cache_url=fga_cache_url) + self._descoper = DescoperAsync(http_client) + self._fga = FGAAsync(http_client, fga_cache_url=fga_cache_url) + self._flow = FlowAsync(http_client) + self._group = GroupAsync(http_client) + self._jwt = JWTAsync(http_client, auth=auth) + self._license = LicenseAsync(http_client) + self._management_key = ManagementKeyAsync(http_client) + self._outbound_application = OutboundApplicationAsync(http_client) + self._outbound_application_by_token = OutboundApplicationByTokenAsync(http_client) + self._permission = PermissionAsync(http_client) + self._project = ProjectAsync(http_client) + self._role = RoleAsync(http_client) + self._sso = SSOSettingsAsync(http_client) + self._sso_application = SSOApplicationAsync(http_client) + self._tenant = TenantAsync(http_client) + self._user = UserAsync(http_client) + + def _ensure_management_key(self, property_name: str): + """Check if management key is available for the given property.""" + if not self._http.management_key: + raise AuthException( + error_type=ERROR_TYPE_INVALID_ARGUMENT, + error_message=f"Management key is required to access '{property_name}' functionality", + ) + + @property + def tenant(self) -> TenantAsync: + self._ensure_management_key("tenant") + return self._tenant + + @property + def sso_application(self) -> SSOApplicationAsync: + self._ensure_management_key("sso_application") + return self._sso_application + + @property + def user(self) -> UserAsync: + self._ensure_management_key("user") + return self._user + + @property + def access_key(self) -> AccessKeyAsync: + self._ensure_management_key("access_key") + return self._access_key + + @property + def sso(self) -> SSOSettingsAsync: + self._ensure_management_key("sso") + return self._sso + + @property + def jwt(self) -> JWTAsync: + self._ensure_management_key("jwt") + return self._jwt + + @property + def license(self) -> LicenseAsync: + self._ensure_management_key("license") + return self._license + + @property + def permission(self) -> PermissionAsync: + self._ensure_management_key("permission") + return self._permission + + @property + def role(self) -> RoleAsync: + self._ensure_management_key("role") + return self._role + + @property + def group(self) -> GroupAsync: + self._ensure_management_key("group") + return self._group + + @property + def flow(self) -> FlowAsync: + self._ensure_management_key("flow") + return self._flow + + @property + def audit(self) -> AuditAsync: + self._ensure_management_key("audit") + return self._audit + + @property + def authz(self) -> AuthzAsync: + self._ensure_management_key("authz") + return self._authz + + @property + def fga(self) -> FGAAsync: + self._ensure_management_key("fga") + return self._fga + + @property + def project(self) -> ProjectAsync: + self._ensure_management_key("project") + return self._project + + @property + def outbound_application(self) -> OutboundApplicationAsync: + self._ensure_management_key("outbound_application") + return self._outbound_application + + @property + def outbound_application_by_token(self) -> OutboundApplicationByTokenAsync: + # No management key check for outbound_app_token (as authentication for those methods is done by inbound app token) + return self._outbound_application_by_token + + @property + def descoper(self) -> DescoperAsync: + self._ensure_management_key("descoper") + return self._descoper + + @property + def management_key(self) -> ManagementKeyAsync: + self._ensure_management_key("management_key") + return self._management_key diff --git a/tests/conftest.py b/tests/conftest.py index fe592d73b..02f90088e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,7 +97,37 @@ def mock_post(self, response): with self._patch_ctx("post", response) as m: yield m - def _patch_ctx(self, method: str, response): + @contextmanager + def mock_mgmt_post(self, response): + with self._patch_ctx("post", response, mgmt=True) as m: + yield m + + @contextmanager + def mock_mgmt_get(self, response): + with self._patch_ctx("get", response, mgmt=True) as m: + yield m + + @contextmanager + def mock_mgmt_put(self, response): + with self._patch_ctx("put", response, mgmt=True) as m: + yield m + + @contextmanager + def mock_mgmt_delete(self, response): + with self._patch_ctx("delete", response, mgmt=True) as m: + yield m + + @contextmanager + def mock_mgmt_patch(self, response): + with self._patch_ctx("patch", response, mgmt=True) as m: + yield m + + @contextmanager + def mock_mgmt_by_token_post(self, response): + with self._patch_ctx("post", response, mgmt_by_token=True) as m: + yield m + + def _patch_ctx(self, method: str, response, *, mgmt: bool = False, mgmt_by_token: bool = False): """ Patch the right layer per mode: @@ -106,11 +136,13 @@ def _patch_ctx(self, method: str, response): """ if self.mode == "sync": return patch(f"httpx.{method}", return_value=response) - return patch.object( - self._raw._auth_http._async_client, - method, - AsyncMock(return_value=response), - ) + if mgmt_by_token: + target = self._raw._mgmt._outbound_application_by_token._http + elif mgmt: + target = self._raw._mgmt._http + else: + target = self._raw._auth_http + return patch.object(target._async_client, method, AsyncMock(return_value=response)) class ClientFactory: diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py index de10d636d..d7c8408b9 100644 --- a/tests/management/test_access_key.py +++ b/tests/management/test_access_key.py @@ -1,74 +1,49 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AssociatedTenant, AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AssociatedTenant, AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestAccessKey(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestAccessKey: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.create, - "key-name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.create("key-name")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"key": {"id": "ak1"}, "cleartext": "abc"}""") - mock_post.return_value = network_resp - resp = client.mgmt.access_key.create( - name="key-name", - expire_time=123456789, - key_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - user_id="userid", - custom_claims={"k1": "v1"}, - description="this is my access key", - permitted_ips=["10.0.0.1", "192.168.1.0/24"], - custom_attributes={"attr1": "value1"}, + with client.mock_mgmt_post(make_response({"key": {"id": "ak1"}, "cleartext": "abc"})) as mock_post: + resp = await client.invoke( + client.mgmt.access_key.create( + name="key-name", + expire_time=123456789, + key_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + user_id="userid", + custom_claims={"k1": "v1"}, + description="this is my access key", + permitted_ips=["10.0.0.1", "192.168.1.0/24"], + custom_attributes={"attr1": "value1"}, + ) ) access_key = resp["key"] - self.assertEqual(access_key["id"], "ak1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_create_path}", + assert access_key["id"] == "ak1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -86,85 +61,61 @@ def test_create(self): "customAttributes": {"attr1": "value1"}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.load, - "key-id", - ) + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.load("key-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"key": {"id": "ak1"}}""") - mock_get.return_value = network_resp - resp = client.mgmt.access_key.load("key-id") + with client.mock_mgmt_get(make_response({"key": {"id": "ak1"}})) as mock_get: + resp = await client.invoke(client.mgmt.access_key.load("key-id")) access_key = resp["key"] - self.assertEqual(access_key["id"], "ak1") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_load_path}", + assert access_key["id"] == "ak1" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"id": "key-id"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search_all_users(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_search_all_users(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.search_all_access_keys, - ["t1, t2"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.search_all_access_keys(["t1, t2"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"keys": [{"id": "ak1"}, {"id": "ak2"}]}""") - mock_post.return_value = network_resp - resp = client.mgmt.access_key.search_all_access_keys( - ["t1, t2"], "bound-user-id", "creator-user", {"attr1": "value1"} + with client.mock_mgmt_post(make_response({"keys": [{"id": "ak1"}, {"id": "ak2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.access_key.search_all_access_keys( + ["t1, t2"], "bound-user-id", "creator-user", {"attr1": "value1"} + ) ) keys = resp["keys"] - self.assertEqual(len(keys), 2) - self.assertEqual(keys[0]["id"], "ak1") - self.assertEqual(keys[1]["id"], "ak2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_keys_search_path}", + assert len(keys) == 2 + assert keys[0]["id"] == "ak1" + assert keys[1]["id"] == "ak2" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_keys_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -174,32 +125,19 @@ def test_search_all_users(self): "customAttributes": {"attr1": "value1"}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.update, - "key-id", - "new-name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.update("key-id", "new-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke( client.mgmt.access_key.update( "key-id", name="new-name", @@ -209,12 +147,15 @@ def test_update(self): custom_attributes={"attr1": "value1"}, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_update_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -226,117 +167,88 @@ def test_update(self): "customAttributes": {"attr1": "value1"}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_deactivate(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_deactivate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.deactivate, - "key-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.deactivate("key-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.access_key.deactivate("ak1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_deactivate_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.deactivate("ak1")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_deactivate_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "id": "ak1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_activate(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_activate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.activate, - "key-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.activate("key-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.access_key.activate("ak1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_activate_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.activate("ak1")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_activate_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "id": "ak1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.access_key.delete, - "key-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.delete("key-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.access_key.delete("ak1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.access_key_delete_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.delete("ak1")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "id": "ak1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_audit.py b/tests/management/test_audit.py index bf5e6b753..9449f3ede 100644 --- a/tests/management/test_audit.py +++ b/tests/management/test_audit.py @@ -1,121 +1,91 @@ from datetime import datetime -from unittest import mock -from unittest.mock import patch -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS -from descope.management.common import MgmtV1 +import pytest -from .. import common -from ..testutils import SSLMatcher +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestAudit(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - def test_search(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +@pytest.mark.asyncio +class TestAudit: + async def test_search(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed search - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.audit.search, - "data", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.audit.search("data")) # Test success search - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "audits": [ - { - "projectId": "p", - "userId": "u1", - "action": "a1", - "externalIds": ["e1"], - "occurred": str(datetime.now().timestamp() * 1000), - }, - { - "projectId": "p", - "userId": "u2", - "action": "a2", - "externalIds": ["e2"], - "occurred": str(datetime.now().timestamp() * 1000), - }, - ] - } - mock_post.return_value = network_resp - resp = client.mgmt.audit.search() + audit_resp = { + "audits": [ + { + "projectId": "p", + "userId": "u1", + "action": "a1", + "externalIds": ["e1"], + "occurred": str(datetime.now().timestamp() * 1000), + }, + { + "projectId": "p", + "userId": "u2", + "action": "a2", + "externalIds": ["e2"], + "occurred": str(datetime.now().timestamp() * 1000), + }, + ] + } + with client.mock_mgmt_post(make_response(audit_resp)) as mock_post: + resp = await client.invoke(client.mgmt.audit.search()) audits = resp["audits"] - self.assertEqual(len(audits), 2) - self.assertEqual(audits[0]["loginIds"][0], "e1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.audit_search}", + assert len(audits) == 2 + assert audits[0]["loginIds"][0] == "e1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.audit_search}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"noTenants": False}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_event(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_event(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed search - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.audit.create_event, "a", "b", "c", "d") + # Test failed create_event + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.audit.create_event("a", "b", "c", "d")) - # Test success search - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = {} - mock_post.return_value = network_resp - client.mgmt.audit.create_event( - action="pencil.created", - user_id="user-id", - actor_id="actor-id", - tenant_id="tenant-id", - type="info", - data={"some": "data"}, + # Test success create_event + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.audit.create_event( + action="pencil.created", + user_id="user-id", + actor_id="actor-id", + tenant_id="tenant-id", + type="info", + data={"some": "data"}, + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.audit_create_event}", + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.audit_create_event}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -127,6 +97,4 @@ def test_create_event(self): "data": {"some": "data"}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_authz.py b/tests/management/test_authz.py index 75dd42c12..f47486374 100644 --- a/tests/management/test_authz.py +++ b/tests/management/test_authz.py @@ -1,145 +1,100 @@ -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestAuthz(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_save_schema(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + +AUTH_HEADERS = { + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, +} + + +class TestAuthz: + async def test_save_schema(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed save_schema - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.save_schema, {}, True) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.save_schema({}, True)) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.save_schema({"name": "kuku"}, True)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_schema_save}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.save_schema({"name": "kuku"}, True)) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_save}", + headers=AUTH_HEADERS, params=None, json={"schema": {"name": "kuku"}, "upgrade": True}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_schema(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_schema(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_schema - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.delete_schema) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.delete_schema()) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.delete_schema()) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_schema_delete}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.delete_schema()) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_delete}", + headers=AUTH_HEADERS, params=None, json=None, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_schema(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_schema(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed load_schema - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.load_schema) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.load_schema()) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.load_schema()) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_schema_load}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"schema": {"name": "kuku"}})) as mock: + result = await client.invoke(client.mgmt.authz.load_schema()) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_schema_load}", + headers=AUTH_HEADERS, params=None, json=None, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_save_namespace(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_save_namespace(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed save_namespace - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.save_namespace, {}) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.save_namespace({})) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.save_namespace({"name": "kuku"}, "old", "v1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_ns_save}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.save_namespace({"name": "kuku"}, "old", "v1")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_ns_save}", + headers=AUTH_HEADERS, params=None, json={ "namespace": {"name": "kuku"}, @@ -147,65 +102,45 @@ def test_save_namespace(self): "schemaName": "v1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_namespace(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_namespace(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_namespace - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.delete_namespace, "a") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.delete_namespace("a")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.delete_namespace("a", "b")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_ns_delete}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.delete_namespace("a", "b")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_ns_delete}", + headers=AUTH_HEADERS, params=None, json={"name": "a", "schemaName": "b"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_save_relation_definition(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_save_relation_definition(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed save_relation_definition - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.save_relation_definition, {}, "a") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.save_relation_definition({}, "a")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.save_relation_definition({"name": "kuku"}, "a", "old", "v1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_rd_save}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.save_relation_definition({"name": "kuku"}, "a", "old", "v1")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_rd_save}", + headers=AUTH_HEADERS, params=None, json={ "relationDefinition": {"name": "kuku"}, @@ -214,58 +149,40 @@ def test_save_relation_definition(self): "schemaName": "v1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_relation_definition(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_relation_definition(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_relation_definition - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.delete_relation_definition, "a", "b") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.delete_relation_definition("a", "b")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.delete_relation_definition("a", "b", "c")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_rd_delete}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.delete_relation_definition("a", "b", "c")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_rd_delete}", + headers=AUTH_HEADERS, params=None, json={"name": "a", "namespace": "b", "schemaName": "c"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed create_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.create_relations, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.create_relations([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke( client.mgmt.authz.create_relations( [ { @@ -277,13 +194,11 @@ def test_create_relations(self): ] ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_create}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_create}", + headers=AUTH_HEADERS, params=None, json={ "relations": [ @@ -296,27 +211,19 @@ def test_create_relations(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.delete_relations, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.delete_relations([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke( client.mgmt.authz.delete_relations( [ { @@ -328,13 +235,11 @@ def test_delete_relations(self): ] ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_delete}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_delete}", + headers=AUTH_HEADERS, params=None, json={ "relations": [ @@ -347,58 +252,40 @@ def test_delete_relations(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_relations_for_resources(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_relations_for_resources(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_relations_for_resources - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.delete_relations_for_resources, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.delete_relations_for_resources([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.authz.delete_relations_for_resources(["r"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_delete_resources}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.authz.delete_relations_for_resources(["r"])) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_delete_resources}", + headers=AUTH_HEADERS, params=None, json={"resources": ["r"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_has_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_has_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed has_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.has_relations, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.has_relations([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone( + with client.mock_mgmt_post(make_response({"relationQueries": []})) as mock: + result = await client.invoke( client.mgmt.authz.has_relations( [ { @@ -410,13 +297,12 @@ def test_has_relations(self): ] ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_has_relations}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_has_relations}", + headers=AUTH_HEADERS, params=None, json={ "relationQueries": [ @@ -429,223 +315,155 @@ def test_has_relations(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_who_can_access(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_who_can_access(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed who_can_access - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.who_can_access, "a", "b", "c") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.who_can_access("a", "b", "c")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_who}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"targets": []})) as mock: + result = await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_who}", + headers=AUTH_HEADERS, params=None, json={"resource": "a", "relationDefinition": "b", "namespace": "c"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_resource_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_resource_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed resource_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.resource_relations, "a") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.resource_relations("a")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.resource_relations("a")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_resource}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"relations": []})) as mock: + result = await client.invoke(client.mgmt.authz.resource_relations("a")) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_resource}", + headers=AUTH_HEADERS, params=None, json={"resource": "a"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_targets_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_targets_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed targets_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.targets_relations, ["a"]) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.targets_relations(["a"])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.targets_relations(["a"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_targets}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"relations": []})) as mock: + result = await client.invoke(client.mgmt.authz.targets_relations(["a"])) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_targets}", + headers=AUTH_HEADERS, params=None, json={"targets": ["a"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_what_can_target_access(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_what_can_target_access(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed what_can_target_access - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.what_can_target_access, "a") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.what_can_target_access("a")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.what_can_target_access("a")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_target_all}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"relations": []})) as mock: + result = await client.invoke(client.mgmt.authz.what_can_target_access("a")) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_target_all}", + headers=AUTH_HEADERS, params=None, json={"target": "a"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_what_can_target_access_with_relation(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_what_can_target_access_with_relation(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed what_can_target_access_with_relation - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.authz.what_can_target_access_with_relation, - "a", - "b", - "c", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.authz.what_can_target_access_with_relation("a", "b", "c") + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.what_can_target_access_with_relation("a", "b", "c")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_re_target_with_relation}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"relations": []})) as mock: + result = await client.invoke( + client.mgmt.authz.what_can_target_access_with_relation("a", "b", "c") + ) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_re_target_with_relation}", + headers=AUTH_HEADERS, params=None, json={"target": "a", "relationDefinition": "b", "namespace": "c"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_get_modified(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_get_modified(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed get_modified - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.authz.get_modified) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.authz.get_modified()) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.authz.get_modified()) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.authz_get_modified}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"relations": []})) as mock: + result = await client.invoke(client.mgmt.authz.get_modified()) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.authz_get_modified}", + headers=AUTH_HEADERS, params=None, json={"since": 0}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_authz_cache_url_who_can_access(self): + async def test_authz_cache_url_who_can_access(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - mock_post.return_value.json.return_value = {"targets": ["u1"]} - result = client.mgmt.authz.who_can_access("a", "b", "c") - mock_post.assert_called_with( + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) + + with client.mock_mgmt_post(make_response({"targets": ["u1"]})) as mock: + result = await client.invoke(client.mgmt.authz.who_can_access("a", "b", "c")) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.authz_re_who}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=AUTH_HEADERS, params=None, json={ "resource": "a", @@ -653,36 +471,22 @@ def test_authz_cache_url_who_can_access(self): "namespace": "c", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - self.assertEqual(result, ["u1"]) + assert result == ["u1"] - def test_authz_cache_url_what_can_target_access(self): + async def test_authz_cache_url_what_can_target_access(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - mock_post.return_value.json.return_value = {"relations": [{"resource": "r1"}]} - result = client.mgmt.authz.what_can_target_access("a") - mock_post.assert_called_with( + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) + + with client.mock_mgmt_post(make_response({"relations": [{"resource": "r1"}]})) as mock: + result = await client.invoke(client.mgmt.authz.what_can_target_access("a")) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.authz_re_target_all}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=AUTH_HEADERS, params=None, json={"target": "a"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - self.assertEqual(result, [{"resource": "r1"}]) + assert result == [{"resource": "r1"}] diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py index 88f6e2611..bad741d35 100644 --- a/tests/management/test_descoper.py +++ b/tests/management/test_descoper.py @@ -1,10 +1,7 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest from descope import ( AuthException, - DescopeClient, DescoperAttributes, DescoperCreate, DescoperProjectRole, @@ -12,104 +9,98 @@ DescoperRole, DescoperTagRole, ) -from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestDescoper(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) +@pytest.mark.asyncio +class TestDescoper: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.put") as mock_put: - mock_put.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.descoper.create, - [ - DescoperCreate( - login_id="user1@example.com", + with client.mock_mgmt_put(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.descoper.create( + [ + DescoperCreate( + login_id="user1@example.com", + ) + ] ) - ], - ) + ) # Test empty descopers - self.assertRaises( - ValueError, - client.mgmt.descoper.create, - [], - ) + with pytest.raises(ValueError): + await client.invoke(client.mgmt.descoper.create([])) # Test success flow - with patch("httpx.put") as mock_put: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{ - "descopers": [{ - "id": "U2111111111111111111111111", - "attributes": { - "displayName": "Test User 2", - "email": "user2@example.com", - "phone": "+123456" - }, - "rbac": { - "isCompanyAdmin": false, - "tags": [], - "projects": [{ - "projectIds": ["P2111111111111111111111111"], - "role": "admin" - }] - }, - "status": "invited" - }], - "total": 1 - }""" + with client.mock_mgmt_put( + make_response( + { + "descopers": [ + { + "id": "U2111111111111111111111111", + "attributes": { + "displayName": "Test User 2", + "email": "user2@example.com", + "phone": "+123456", + }, + "rbac": { + "isCompanyAdmin": False, + "tags": [], + "projects": [ + { + "projectIds": ["P2111111111111111111111111"], + "role": "admin", + } + ], + }, + "status": "invited", + } + ], + "total": 1, + } ) - mock_put.return_value = network_resp - resp = client.mgmt.descoper.create( - descopers=[ - DescoperCreate( - login_id="user1@example.com", - attributes=DescoperAttributes( - display_name="Test User 2", - phone="+123456", - email="user2@example.com", - ), - rbac=DescoperRBAC( - projects=[ - DescoperProjectRole( - project_ids=["P2111111111111111111111111"], - role=DescoperRole.ADMIN, - ) - ], - ), - ) - ], + ) as mock_put: + resp = await client.invoke( + client.mgmt.descoper.create( + descopers=[ + DescoperCreate( + login_id="user1@example.com", + attributes=DescoperAttributes( + display_name="Test User 2", + phone="+123456", + email="user2@example.com", + ), + rbac=DescoperRBAC( + projects=[ + DescoperProjectRole( + project_ids=["P2111111111111111111111111"], + role=DescoperRole.ADMIN, + ) + ], + ), + ) + ], + ) ) descopers = resp["descopers"] - self.assertEqual(len(descopers), 1) - self.assertEqual(descopers[0]["id"], "U2111111111111111111111111") - self.assertEqual(resp["total"], 1) - mock_put.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", + assert len(descopers) == 1 + assert descopers[0]["id"] == "U2111111111111111111111111" + assert resp["total"] == 1 + assert_http_called( + mock_put, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -136,67 +127,64 @@ def test_create(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_with_tag_roles(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_create_with_tag_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow with tag roles - with patch("httpx.put") as mock_put: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "descopers": [ - { - "id": "U2111111111111111111111111", - "attributes": { - "displayName": "Test User", - "email": "user@example.com", - "phone": "", - }, - "rbac": { - "isCompanyAdmin": False, - "tags": [{"tags": ["tag1", "tag2"], "role": "auditor"}], - "projects": [], - }, - "status": "invited", - } - ], - "total": 1, - } - mock_put.return_value = network_resp - resp = client.mgmt.descoper.create( - descopers=[ - DescoperCreate( - login_id="user@example.com", - rbac=DescoperRBAC( - tags=[ - DescoperTagRole( - tags=["tag1", "tag2"], - role=DescoperRole.AUDITOR, - ) - ], - ), - ) - ], + with client.mock_mgmt_put( + make_response( + { + "descopers": [ + { + "id": "U2111111111111111111111111", + "attributes": { + "displayName": "Test User", + "email": "user@example.com", + "phone": "", + }, + "rbac": { + "isCompanyAdmin": False, + "tags": [{"tags": ["tag1", "tag2"], "role": "auditor"}], + "projects": [], + }, + "status": "invited", + } + ], + "total": 1, + } + ) + ) as mock_put: + resp = await client.invoke( + client.mgmt.descoper.create( + descopers=[ + DescoperCreate( + login_id="user@example.com", + rbac=DescoperRBAC( + tags=[ + DescoperTagRole( + tags=["tag1", "tag2"], + role=DescoperRole.AUDITOR, + ) + ], + ), + ) + ], + ) ) descopers = resp["descopers"] - self.assertEqual(len(descopers), 1) - self.assertEqual(len(descopers[0]["rbac"]["tags"]), 1) - self.assertEqual(descopers[0]["rbac"]["tags"][0]["tags"], ["tag1", "tag2"]) - mock_put.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", + assert len(descopers) == 1 + assert len(descopers[0]["rbac"]["tags"]) == 1 + assert descopers[0]["rbac"]["tags"][0]["tags"] == ["tag1", "tag2"] + assert_http_called( + mock_put, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -214,139 +202,119 @@ def test_create_with_tag_roles(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.descoper.load, - "descoper-id", - ) + with client.mock_mgmt_get(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.descoper.load("descoper-id")) # Test empty id - self.assertRaises( - ValueError, - client.mgmt.descoper.load, - "", - ) + with pytest.raises(ValueError): + await client.invoke(client.mgmt.descoper.load("")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{ + with client.mock_mgmt_get( + make_response( + { "descoper": { "id": "U2222222222222222222222222", "attributes": { "displayName": "Test User 2", "email": "user2@example.com", - "phone": "+123456" + "phone": "+123456", }, "rbac": { - "isCompanyAdmin": false, + "isCompanyAdmin": False, "tags": [], - "projects": [{ - "projectIds": ["P2111111111111111111111111"], - "role": "admin" - }] + "projects": [ + { + "projectIds": ["P2111111111111111111111111"], + "role": "admin", + } + ], }, - "status": "invited" + "status": "invited", } - }""" + } ) - mock_get.return_value = network_resp - resp = client.mgmt.descoper.load("U2222222222222222222222222") + ) as mock_get: + resp = await client.invoke(client.mgmt.descoper.load("U2222222222222222222222222")) descoper = resp["descoper"] - self.assertEqual(descoper["id"], "U2222222222222222222222222") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_load_path}", + assert descoper["id"] == "U2222222222222222222222222" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"id": "U2222222222222222222222222"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.patch") as mock_patch: - mock_patch.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.descoper.update, - "descoper-id", - None, - DescoperRBAC(is_company_admin=True), - ) + with client.mock_mgmt_patch(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.descoper.update( + "descoper-id", + None, + DescoperRBAC(is_company_admin=True), + ) + ) # Test empty id - self.assertRaises( - ValueError, - client.mgmt.descoper.update, - "", - ) + with pytest.raises(ValueError): + await client.invoke(client.mgmt.descoper.update("")) # Test success flow - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{ + with client.mock_mgmt_patch( + make_response( + { "descoper": { "id": "U2333333333333333333333333", "attributes": { "displayName": "Updated User", "email": "user4@example.com", - "phone": "+1234358730" + "phone": "+1234358730", }, "rbac": { - "isCompanyAdmin": true, + "isCompanyAdmin": True, "tags": [], - "projects": [] + "projects": [], }, - "status": "invited" + "status": "invited", } - }""" + } ) - mock_patch.return_value = network_resp - resp = client.mgmt.descoper.update( - "U2333333333333333333333333", - DescoperAttributes("Updated User", "user4@example.com", "+1234358730"), - DescoperRBAC(is_company_admin=True), + ) as mock_patch: + resp = await client.invoke( + client.mgmt.descoper.update( + "U2333333333333333333333333", + DescoperAttributes("Updated User", "user4@example.com", "+1234358730"), + DescoperRBAC(is_company_admin=True), + ) ) descoper = resp["descoper"] - self.assertEqual(descoper["id"], "U2333333333333333333333333") - self.assertTrue(descoper["rbac"]["isCompanyAdmin"]) - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_update_path}", + assert descoper["id"] == "U2333333333333333333333333" + assert descoper["rbac"]["isCompanyAdmin"] is True + assert_http_called( + mock_patch, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -363,153 +331,130 @@ def test_update(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.descoper.delete, - "descoper-id", - ) + with client.mock_mgmt_delete(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.descoper.delete("descoper-id")) # Test empty id - self.assertRaises( - ValueError, - client.mgmt.descoper.delete, - "", - ) + with pytest.raises(ValueError): + await client.invoke(client.mgmt.descoper.delete("")) # Test success flow - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = True - self.assertIsNone(client.mgmt.descoper.delete("U2111111111111111111111111")) - mock_delete.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_delete_path}", + with client.mock_mgmt_delete(make_response()) as mock_delete: + assert await client.invoke(client.mgmt.descoper.delete("U2111111111111111111111111")) is None + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_delete_path}", params={"id": "U2111111111111111111111111"}, headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_list(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_list(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.descoper.list, - ) + with client.mock_mgmt_post(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.descoper.list()) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{ + with client.mock_mgmt_post( + make_response( + { "descopers": [ { "id": "U2444444444444444444444444", "attributes": { "displayName": "Admin User", "email": "admin@example.com", - "phone": "" + "phone": "", }, "rbac": { - "isCompanyAdmin": true, + "isCompanyAdmin": True, "tags": [], - "projects": [] + "projects": [], }, - "status": "enabled" + "status": "enabled", }, { "id": "U2555555555555555555555555", "attributes": { "displayName": "Another User", "email": "user3@example.com", - "phone": "+123456" + "phone": "+123456", }, "rbac": { - "isCompanyAdmin": false, + "isCompanyAdmin": False, "tags": [], - "projects": [] + "projects": [], }, - "status": "invited" + "status": "invited", }, { "id": "U2666666666666666666666666", "attributes": { "displayName": "Test User 1", "email": "user2@example.com", - "phone": "+123456" + "phone": "+123456", }, "rbac": { - "isCompanyAdmin": false, + "isCompanyAdmin": False, "tags": [], - "projects": [{ - "projectIds": ["P2222222222222222222222222"], - "role": "admin" - }] + "projects": [ + { + "projectIds": ["P2222222222222222222222222"], + "role": "admin", + } + ], }, - "status": "invited" - } + "status": "invited", + }, ], - "total": 3 - }""" + "total": 3, + } ) - mock_post.return_value = network_resp - resp = client.mgmt.descoper.list() + ) as mock_post: + resp = await client.invoke(client.mgmt.descoper.list()) descopers = resp["descopers"] - self.assertEqual(len(descopers), 3) - self.assertEqual(resp["total"], 3) + assert len(descopers) == 3 + assert resp["total"] == 3 # First descoper - company admin - self.assertEqual(descopers[0]["id"], "U2444444444444444444444444") - self.assertEqual(descopers[0]["attributes"]["displayName"], "Admin User") - self.assertTrue(descopers[0]["rbac"]["isCompanyAdmin"]) - self.assertEqual(descopers[0]["status"], "enabled") + assert descopers[0]["id"] == "U2444444444444444444444444" + assert descopers[0]["attributes"]["displayName"] == "Admin User" + assert descopers[0]["rbac"]["isCompanyAdmin"] is True + assert descopers[0]["status"] == "enabled" # Second descoper - self.assertEqual(descopers[1]["id"], "U2555555555555555555555555") - self.assertFalse(descopers[1]["rbac"]["isCompanyAdmin"]) + assert descopers[1]["id"] == "U2555555555555555555555555" + assert descopers[1]["rbac"]["isCompanyAdmin"] is False # Third descoper - with project role - self.assertEqual(descopers[2]["id"], "U2666666666666666666666666") - self.assertEqual(len(descopers[2]["rbac"]["projects"]), 1) + assert descopers[2]["id"] == "U2666666666666666666666666" + assert len(descopers[2]["rbac"]["projects"]) == 1 - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_list_path}", + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.descoper_list_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py index 967fd1adc..7763d31db 100644 --- a/tests/management/test_fga.py +++ b/tests/management/test_fga.py @@ -1,488 +1,254 @@ -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestFGA(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + +MGMT_HEADERS = { + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, +} + +TUPLE = { + "resource": "r", + "resourceType": "rt", + "relation": "rel", + "target": "u", + "targetType": "ty", +} - def test_save_schema(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + +class TestFGA: + async def test_save_schema(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed save_schema - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.fga.save_schema, "") + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.save_schema("")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.fga.save_schema("model AuthZ 1.0")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", + headers=MGMT_HEADERS, params=None, json={"dsl": "model AuthZ 1.0"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed create_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.fga.create_relations, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.create_relations([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.fga.create_relations( - [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_create_relations}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.fga.create_relations([TUPLE])) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_create_relations}", + headers=MGMT_HEADERS, params=None, - json={ - "tuples": [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - }, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_relations(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_relations(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.fga.delete_relations, []) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.delete_relations([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.fga.delete_relations( - [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_delete_relations}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.fga.delete_relations([TUPLE])) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_delete_relations}", + headers=MGMT_HEADERS, params=None, - json={ - "tuples": [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - }, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_check(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_check(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed has_relations - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.fga.check, []) + # Test failed check + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.check([])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone( - client.mgmt.fga.check( - [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_check}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"tuples": []})) as mock: + result = await client.invoke(client.mgmt.fga.check([TUPLE])) + assert result is not None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_check}", + headers=MGMT_HEADERS, params=None, - json={ - "tuples": [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] - }, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_resources_details_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_resources_details_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") response_body = { "resourcesDetails": [ {"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}, {"resourceId": "r2", "resourceType": "type2", "displayName": "Name2"}, ] } - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - mock_post.return_value.json.return_value = response_body - ids = [ - {"resourceId": "r1", "resourceType": "type1"}, - {"resourceId": "r2", "resourceType": "type2"}, - ] - details = client.mgmt.fga.load_resources_details(ids) - self.assertEqual(details, response_body["resourcesDetails"]) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_resources_load}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + ids = [ + {"resourceId": "r1", "resourceType": "type1"}, + {"resourceId": "r2", "resourceType": "type2"}, + ] + with client.mock_mgmt_post(make_response(response_body)) as mock: + details = await client.invoke(client.mgmt.fga.load_resources_details(ids)) + assert details == response_body["resourcesDetails"] + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_resources_load}", + headers=MGMT_HEADERS, params=None, json={"resourceIdentifiers": ids}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_resources_details_error(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - ids = [{"resourceId": "r1", "resourceType": "type1"}] - self.assertRaises( - AuthException, - client.mgmt.fga.load_resources_details, - ids, - ) + async def test_load_resources_details_error(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + ids = [{"resourceId": "r1", "resourceType": "type1"}] + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.load_resources_details(ids)) - def test_save_resources_details_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_save_resources_details_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") details = [{"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}] - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - client.mgmt.fga.save_resources_details(details) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_resources_save}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.fga.save_resources_details(details)) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_resources_save}", + headers=MGMT_HEADERS, params=None, json={"resourcesDetails": details}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_save_resources_details_error(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_save_resources_details_error(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") details = [{"resourceId": "r1", "resourceType": "type1", "displayName": "Name1"}] - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.fga.save_resources_details, - details, - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.save_resources_details(details)) - def test_fga_cache_url_save_schema(self): - # Test FGA cache URL functionality for save_schema + async def test_fga_cache_url_save_schema(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - client.mgmt.fga.save_schema("model AuthZ 1.0") - mock_post.assert_called_with( + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) + + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.fga_save_schema}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=MGMT_HEADERS, params=None, json={"dsl": "model AuthZ 1.0"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_fga_cache_url_create_relations(self): - # Test FGA cache URL functionality for create_relations + async def test_fga_cache_url_create_relations(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - relations = [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - client.mgmt.fga.create_relations(relations) - mock_post.assert_called_with( + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.fga.create_relations([TUPLE])) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.fga_create_relations}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=MGMT_HEADERS, params=None, - json={"tuples": relations}, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_fga_cache_url_delete_relations(self): - # Test FGA cache URL functionality for delete_relations + async def test_fga_cache_url_delete_relations(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - relations = [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - client.mgmt.fga.delete_relations(relations) - mock_post.assert_called_with( + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.fga.delete_relations([TUPLE])) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.fga_delete_relations}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=MGMT_HEADERS, params=None, - json={"tuples": relations}, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_fga_cache_url_check(self): - # Test FGA cache URL functionality for check + async def test_fga_cache_url_check(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - fga_cache_url=fga_cache_url, - ) - - relations = [ - { - "resource": "r", - "resourceType": "rt", - "relation": "rel", - "target": "u", - "targetType": "ty", - } - ] + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) + + response_body = { + "tuples": [ + { + "allowed": True, + "tuple": TUPLE, + } + ] + } - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - mock_post.return_value.json.return_value = { - "tuples": [ - { - "allowed": True, - "tuple": relations[0], - } - ] - } - result = client.mgmt.fga.check(relations) - mock_post.assert_called_with( + with client.mock_mgmt_post(make_response(response_body)) as mock: + result = await client.invoke(client.mgmt.fga.check([TUPLE])) + assert_http_called( + mock, + client.mode, f"{fga_cache_url}{MgmtV1.fga_check}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + headers=MGMT_HEADERS, params=None, - json={"tuples": relations}, + json={"tuples": [TUPLE]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - self.assertEqual(len(result), 1) - self.assertTrue(result[0]["allowed"]) - self.assertEqual(result[0]["relation"], relations[0]) - - def test_fga_without_cache_url_uses_default_base_url(self): - # Test that FGA methods use default base URL when cache URL is not provided - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - # No fga_cache_url provided - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - client.mgmt.fga.save_schema("model AuthZ 1.0") - # Should use default base URL - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert len(result) == 1 + assert result[0]["allowed"] is True + assert result[0]["relation"] == TUPLE + + async def test_fga_without_cache_url_uses_default_base_url(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response()) as mock: + await client.invoke(client.mgmt.fga.save_schema("model AuthZ 1.0")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_save_schema}", + headers=MGMT_HEADERS, params=None, json={"dsl": "model AuthZ 1.0"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_flow.py b/tests/management/test_flow.py index 34e265d5e..89f3831d3 100644 --- a/tests/management/test_flow.py +++ b/tests/management/test_flow.py @@ -1,163 +1,112 @@ -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import FlowRunOptions, MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestFlow(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_list_flows(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + + +class TestFlow: + async def test_list_flows(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.list_flows, - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.list_flows()) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.list_flows()) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_list_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.list_flows()) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_list_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json=None, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_flows(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_flows(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed delete flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.delete_flows, - ["flow-1", "flow-2"], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.delete_flows(["flow-1", "flow-2"])) # Test success delete flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.delete_flows(["flow-1", "flow-2"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_delete_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.delete_flows(["flow-1", "flow-2"])) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"ids": ["flow-1", "flow-2"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_export_flow(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_export_flow(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.export_flow, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.export_flow("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.export_flow("test")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_export_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.export_flow("test")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_export_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, - json={ - "flowId": "test", - }, + json={"flowId": "test"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_import_flow(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_import_flow(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.import_flow, - "name", - {"name": "test"}, - [{"id": "test"}], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.import_flow("name", {"name": "test"}, [{"id": "test"}])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.import_flow("name", {"name": "test"}, [{"id": "test"}])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_import_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.import_flow("name", {"name": "test"}, [{"id": "test"}])) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_import_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -166,122 +115,103 @@ def test_import_flow(self): "screens": [{"id": "test"}], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_export_theme(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_export_theme(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.flow.export_theme) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.export_theme()) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.export_theme()) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.theme_export_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.export_theme()) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.theme_export_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_import_theme(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_import_theme(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.flow.import_theme, {"id": "test"}) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.import_theme({"id": "test"})) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.import_theme({"id": "test"})) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.theme_import_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.import_theme({"id": "test"})) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.theme_import_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"theme": {"id": "test"}}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_run_flow(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_run_flow(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed run flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.run_flow, - "test-flow", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.run_flow("test-flow")) # Test success run flow with no options - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.run_flow("test-flow")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.run_flow("test-flow")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"flowId": "test-flow"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success run flow with dict options - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.flow.run_flow( "test-flow", {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -291,25 +221,25 @@ def test_run_flow(self): "tenant": "tenant-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success run flow with FlowRunOptions object - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True + with client.mock_mgmt_post(make_response()) as mock_post: options = FlowRunOptions( flow_input={"key": "value"}, preview=True, tenant="tenant-id", ) - self.assertIsNotNone(client.mgmt.flow.run_flow("test-flow", options)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", + result = await client.invoke(client.mgmt.flow.run_flow("test-flow", options)) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -319,71 +249,62 @@ def test_run_flow(self): "tenant": "tenant-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_flow_run_options_from_dict(self): + async def test_flow_run_options_from_dict(self, client_factory): # Test from_dict with None returns None - self.assertIsNone(FlowRunOptions.from_dict(None)) + assert FlowRunOptions.from_dict(None) is None # Test from_dict with valid dict options = FlowRunOptions.from_dict({"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}) - self.assertIsNotNone(options) - self.assertEqual(options.flow_input, {"key": "value"}) - self.assertEqual(options.preview, True) - self.assertEqual(options.tenant, "tenant-id") - - def test_run_flow_async(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + assert options is not None + assert options.flow_input == {"key": "value"} + assert options.preview == True + assert options.tenant == "tenant-id" + + async def test_run_flow_async(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed run flow async - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.run_flow_async, - "test-flow", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.run_flow_async("test-flow")) # Test success run flow async with no options - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.run_flow_async("test-flow")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.run_flow_async("test-flow")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"flowId": "test-flow"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success run flow async with dict options - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.flow.run_flow_async( "test-flow", {"input": {"key": "value"}, "preview": True, "tenant": "tenant-id"}, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -393,25 +314,25 @@ def test_run_flow_async(self): "tenant": "tenant-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success run flow async with FlowRunOptions object - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True + with client.mock_mgmt_post(make_response()) as mock_post: options = FlowRunOptions( flow_input={"key": "value"}, preview=True, tenant="tenant-id", ) - self.assertIsNotNone(client.mgmt.flow.run_flow_async("test-flow", options)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", + result = await client.invoke(client.mgmt.flow.run_flow_async("test-flow", options)) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_run_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -421,41 +342,30 @@ def test_run_flow_async(self): "tenant": "tenant-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_get_flow_async_result(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_get_flow_async_result(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed get flow async result - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.flow.get_flow_async_result, - "execution-123", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.flow.get_flow_async_result("execution-123")) # Test success get flow async result - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.flow.get_flow_async_result("execution-123")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.flow_async_result_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.flow.get_flow_async_result("execution-123")) + assert result is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.flow_async_result_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"executionId": "execution-123"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_group.py b/tests/management/test_group.py index 1d06b4a85..21bfdf8de 100644 --- a/tests/management/test_group.py +++ b/tests/management/test_group.py @@ -1,94 +1,65 @@ -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestGroup(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_load_all_groups(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestGroup: + async def test_load_all_groups(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.group.load_all_groups, - "tenant_id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.group.load_all_groups("tenant_id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.group.load_all_groups("someTenantId")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.group_load_all_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.group.load_all_groups("someTenantId")) is not None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "tenantId": "someTenantId", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all_groups_for_members(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all_groups_for_members(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.group.load_all_groups_for_members, - "tenant_id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.group.load_all_groups_for_members("tenant_id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone( - client.mgmt.group.load_all_groups_for_members("someTenantId", ["one", "two"], ["three", "four"]) + with client.mock_mgmt_post(make_response({})) as mock_post: + assert ( + await client.invoke( + client.mgmt.group.load_all_groups_for_members("someTenantId", ["one", "two"], ["three", "four"]) + ) + is not None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.group_load_all_for_member_path}", + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_for_member_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -97,38 +68,30 @@ def test_load_all_groups_for_members(self): "userIds": ["one", "two"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all_group_members(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all_group_members(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.group.load_all_group_members, - "tenant_id", - "group_id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.group.load_all_group_members("tenant_id", "group_id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNotNone(client.mgmt.group.load_all_group_members("someTenantId", "someGroupId")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.group_load_all_group_members_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert ( + await client.invoke(client.mgmt.group.load_all_group_members("someTenantId", "someGroupId")) + is not None + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.group_load_all_group_members_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -136,6 +99,4 @@ def test_load_all_group_members(self): "groupId": "someGroupId", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index 1535910a7..e1f138fc6 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -1,60 +1,37 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtLoginOptions, MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestJWT(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_update_jwt(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestJWT: + async def test_update_jwt(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.jwt.update_jwt, "jwt", {"k1": "v1"}, 0) + with client.mock_mgmt_post(make_response({}, status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.update_jwt("jwt", {"k1": "v1"}, 0)) - self.assertRaises(AuthException, client.mgmt.jwt.update_jwt, "", {"k1": "v1"}, 0) + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.update_jwt("", {"k1": "v1"}, 0)) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - resp = client.mgmt.jwt.update_jwt("test", {"k1": "v1"}, 40) - self.assertEqual(resp, "response") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.update_jwt("test", {"k1": "v1"}, 40)) + assert resp == "response" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "jwt": "test", @@ -63,19 +40,19 @@ def test_update_jwt(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - resp = client.mgmt.jwt.update_jwt("test", {"k1": "v1"}) - self.assertEqual(resp, "response") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.update_jwt("test", {"k1": "v1"})) + assert resp == "response" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.update_jwt_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "jwt": "test", @@ -84,42 +61,35 @@ def test_update_jwt(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_impersonate(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_impersonate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.jwt.impersonate, "imp1", "imp2", False) + with client.mock_mgmt_post(make_response({}, status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", False)) - self.assertRaises(AuthException, client.mgmt.jwt.impersonate, "", "imp2", False) + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate("", "imp2", False)) - self.assertRaises(AuthException, client.mgmt.jwt.impersonate, "imp1", "", False) + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate("imp1", "", False)) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - resp = client.mgmt.jwt.impersonate("imp1", "imp2", True) - self.assertEqual(resp, "response") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.impersonate_path}" - mock_post.assert_called_with( + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", True)) + assert resp == "response" + expected_uri = f"{DEFAULT_BASE_URL}{MgmtV1.impersonate_path}" + assert_http_called( + mock, + client.mode, expected_uri, headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "imp2", @@ -132,24 +102,20 @@ def test_impersonate(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test stepup flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "stepup_response"}""") - mock_post.return_value = network_resp - resp = client.mgmt.jwt.impersonate("imp1", "imp2", True, stepup=True) - self.assertEqual(resp, "stepup_response") - mock_post.assert_called_with( + with client.mock_mgmt_post(make_response({"jwt": "stepup_response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.impersonate("imp1", "imp2", True, stepup=True)) + assert resp == "stepup_response" + assert_http_called( + mock, + client.mode, expected_uri, headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "imp2", @@ -162,42 +128,28 @@ def test_impersonate(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_stop_impersonation(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_stop_impersonation(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.jwt.stop_impersonation, - "", - ) + with client.mock_mgmt_post(make_response({}, status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.stop_impersonation("")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - resp = client.mgmt.jwt.stop_impersonation("jwtstr") - self.assertEqual(resp, "response") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.stop_impersonation_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.stop_impersonation("jwtstr")) + assert resp == "response" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.stop_impersonation_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "jwt": "jwtstr", @@ -207,42 +159,29 @@ def test_stop_impersonation(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_sign_in(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_sign_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - self.assertRaises(AuthException, client.mgmt.jwt.sign_in, "") + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.sign_in("")) - self.assertRaises( - AuthException, - client.mgmt.jwt.sign_in, - "loginId", - MgmtLoginOptions(mfa=True), - ) + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.sign_in("loginId", MgmtLoginOptions(mfa=True))) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - client.mgmt.jwt.sign_in("loginId") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_in_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + await client.invoke(client.mgmt.jwt.sign_in("loginId")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_in_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "loginId", @@ -255,35 +194,26 @@ def test_sign_in(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_sign_up(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_sign_up(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - self.assertRaises(AuthException, client.mgmt.jwt.sign_up, "") + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.sign_up("")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - client.mgmt.jwt.sign_up("loginId") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + await client.invoke(client.mgmt.jwt.sign_up("loginId")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "loginId", @@ -306,35 +236,26 @@ def test_sign_up(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_sign_up_or_in(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_sign_up_or_in(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - self.assertRaises(AuthException, client.mgmt.jwt.sign_up_or_in, "") + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.sign_up_or_in("")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - client.mgmt.jwt.sign_up_or_in("loginId") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_or_in_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + await client.invoke(client.mgmt.jwt.sign_up_or_in("loginId")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_sign_up_or_in_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "loginId", @@ -357,32 +278,22 @@ def test_sign_up_or_in(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_anonymous(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_anonymous(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"jwt": "response"}""") - mock_post.return_value = network_resp - client.mgmt.jwt.anonymous({"k1": "v1"}, "id") - expected_uri = f"{common.DEFAULT_BASE_URL}{MgmtV1.anonymous_path}" - mock_post.assert_called_with( - expected_uri, + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + await client.invoke(client.mgmt.jwt.anonymous({"k1": "v1"}, "id")) + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.anonymous_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "customClaims": {"k1": "v1"}, @@ -391,6 +302,4 @@ def test_anonymous(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_license.py b/tests/management/test_license.py index 75c78f743..9d0d49e2a 100644 --- a/tests/management/test_license.py +++ b/tests/management/test_license.py @@ -1,85 +1,52 @@ from unittest import mock -from unittest.mock import patch + +import pytest from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestLicense(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_get_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.license.get) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL +from tests.testutils import PUBLIC_KEY_DICT - def test_get_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = {"rateLimitTier": "tier4"} - mock_get.return_value = network_resp - - resp = client.mgmt.license.get() - self.assertEqual(resp, {"rateLimitTier": "tier4"}) - - mock_get.assert_called_with( - f"{client._mgmt_http_client.base_url}{MgmtV1.license_get_path}", - headers=mock.ANY, - params=None, - follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, - ) +# Infrastructure tests — sync only, test HTTP client internals directly +class TestLicenseInfra: def test_header_injected_after_handshake(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + client = DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Simulate a completed handshake by setting the cached tier directly. client._mgmt_http_client.rate_limit_tier = "tier2" headers = client._mgmt_http_client._get_default_headers() - self.assertEqual(headers.get("x-descope-license"), "tier2") + assert headers.get("x-descope-license") == "tier2" def test_header_absent_when_tier_not_cached(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + client = DescopeClient(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Default state has no rate limit tier yet. client._mgmt_http_client.rate_limit_tier = None headers = client._mgmt_http_client._get_default_headers() - self.assertNotIn("x-descope-license", headers) + assert "x-descope-license" not in headers + + +# Parametrized async-style tests for management API behavior +@pytest.mark.asyncio +class TestLicense: + async def test_get_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with client.mock_mgmt_get(make_response(status=400)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.license.get()) + + async def test_get_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with client.mock_mgmt_get(make_response({"rateLimitTier": "tier4"})) as mock_get: + resp = await client.invoke(client.mgmt.license.get()) + assert resp == {"rateLimitTier": "tier4"} + + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.license_get_path}", + headers=mock.ANY, + params=None, + follow_redirects=True, + ) diff --git a/tests/management/test_mgmtkey.py b/tests/management/test_mgmtkey.py index fd76c6f5f..0f71d1889 100644 --- a/tests/management/test_mgmtkey.py +++ b/tests/management/test_mgmtkey.py @@ -1,109 +1,96 @@ -from unittest import mock -from unittest.mock import patch +import pytest from descope import ( - DescopeClient, + AuthException, MgmtKeyProjectRole, MgmtKeyReBac, MgmtKeyStatus, MgmtKeyTagRole, ) -from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestManagementKey(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - - def test_create_empty_name(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.create( - name="", - rebac=MgmtKeyReBac(company_roles=["role1"]), +class TestManagementKey: + async def test_create_empty_name(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke( + client.mgmt.management_key.create( + name="", + rebac=MgmtKeyReBac(company_roles=["role1"]), + ) ) - self.assertEqual(str(context.exception), "name cannot be empty") + assert str(exc_info.value) == "name cannot be empty" - def test_create_none_rebac(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.create( - name="test-key", - rebac=None, + async def test_create_none_rebac(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke( + client.mgmt.management_key.create( + name="test-key", + rebac=None, + ) ) - self.assertEqual(str(context.exception), "rebac cannot be empty") + assert str(exc_info.value) == "rebac cannot be empty" - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.put") as mock_put: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "cleartext": "cleartext-secret", - "key": { - "id": "mk1", - "name": "test-key", - "description": "test key", - "permittedIps": ["10.0.0.1"], - "status": "active", - "createdTime": 1764849768, - "expireTime": 3600, - "reBac": { - "companyRoles": ["role1"], - "projectRoles": [], - "tagRoles": [], + with client.mock_mgmt_put( + make_response( + { + "cleartext": "cleartext-secret", + "key": { + "id": "mk1", + "name": "test-key", + "description": "test key", + "permittedIps": ["10.0.0.1"], + "status": "active", + "createdTime": 1764849768, + "expireTime": 3600, + "reBac": { + "companyRoles": ["role1"], + "projectRoles": [], + "tagRoles": [], + }, + "version": 1, + "authzVersion": 1, }, - "version": 1, - "authzVersion": 1, - }, - } - mock_put.return_value = network_resp - resp = client.mgmt.management_key.create( - name="test-key", - rebac=MgmtKeyReBac(company_roles=["role1"]), - description="test key", - expires_in=3600, - permitted_ips=["10.0.0.1"], + } + ) + ) as mock_put: + resp = await client.invoke( + client.mgmt.management_key.create( + name="test-key", + rebac=MgmtKeyReBac(company_roles=["role1"]), + description="test key", + expires_in=3600, + permitted_ips=["10.0.0.1"], + ) ) - self.assertEqual(resp["cleartext"], "cleartext-secret") + assert resp["cleartext"] == "cleartext-secret" key = resp["key"] - self.assertEqual(key["name"], "test-key") - self.assertEqual(key["description"], "test key") - self.assertEqual(len(key["permittedIps"]), 1) - self.assertEqual(key["permittedIps"][0], "10.0.0.1") - self.assertEqual(key["expireTime"], 3600) - self.assertIsNotNone(key["reBac"]) - self.assertEqual(len(key["reBac"]["companyRoles"]), 1) - self.assertEqual(key["reBac"]["companyRoles"][0], "role1") - mock_put.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", + assert key["name"] == "test-key" + assert key["description"] == "test key" + assert len(key["permittedIps"]) == 1 + assert key["permittedIps"][0] == "10.0.0.1" + assert key["expireTime"] == 3600 + assert key["reBac"] is not None + assert len(key["reBac"]["companyRoles"]) == 1 + assert key["reBac"]["companyRoles"][0] == "role1" + assert_http_called( + mock_put, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -116,62 +103,59 @@ def test_create(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_with_project_and_tag_roles(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_create_with_project_and_tag_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow with project_roles and tag_roles - with patch("httpx.put") as mock_put: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "cleartext": "cleartext-secret", - "key": { - "id": "mk1", - "name": "test-key", - "description": "test key", - "permittedIps": [], - "status": "active", - "createdTime": 1764849768, - "expireTime": 0, - "reBac": { - "companyRoles": [], - "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], - "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], + with client.mock_mgmt_put( + make_response( + { + "cleartext": "cleartext-secret", + "key": { + "id": "mk1", + "name": "test-key", + "description": "test key", + "permittedIps": [], + "status": "active", + "createdTime": 1764849768, + "expireTime": 0, + "reBac": { + "companyRoles": [], + "projectRoles": [{"projectIds": ["proj1"], "roles": ["admin"]}], + "tagRoles": [{"tags": ["tag1"], "roles": ["viewer"]}], + }, + "version": 1, + "authzVersion": 1, }, - "version": 1, - "authzVersion": 1, - }, - } - mock_put.return_value = network_resp - resp = client.mgmt.management_key.create( - name="test-key", - rebac=MgmtKeyReBac( - project_roles=[MgmtKeyProjectRole(project_ids=["proj1"], roles=["admin"])], - tag_roles=[MgmtKeyTagRole(tags=["tag1"], roles=["viewer"])], - ), + } + ) + ) as mock_put: + resp = await client.invoke( + client.mgmt.management_key.create( + name="test-key", + rebac=MgmtKeyReBac( + project_roles=[MgmtKeyProjectRole(project_ids=["proj1"], roles=["admin"])], + tag_roles=[MgmtKeyTagRole(tags=["tag1"], roles=["viewer"])], + ), + ) ) - self.assertEqual(resp["cleartext"], "cleartext-secret") + assert resp["cleartext"] == "cleartext-secret" key = resp["key"] - self.assertEqual(key["name"], "test-key") - self.assertEqual(len(key["reBac"]["projectRoles"]), 1) - self.assertEqual(key["reBac"]["projectRoles"][0]["projectIds"], ["proj1"]) - self.assertEqual(len(key["reBac"]["tagRoles"]), 1) - self.assertEqual(key["reBac"]["tagRoles"][0]["tags"], ["tag1"]) - mock_put.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", + assert key["name"] == "test-key" + assert len(key["reBac"]["projectRoles"]) == 1 + assert key["reBac"]["projectRoles"][0]["projectIds"] == ["proj1"] + assert len(key["reBac"]["tagRoles"]) == 1 + assert key["reBac"]["tagRoles"][0]["tags"] == ["tag1"] + assert_http_called( + mock_put, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -185,112 +169,100 @@ def test_create_with_project_and_tag_roles(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_empty_id(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.update( - id="", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, + async def test_update_empty_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke( + client.mgmt.management_key.update( + id="", + name="updated-key", + description="updated key", + permitted_ips=["1.2.3.4"], + status=MgmtKeyStatus.INACTIVE, + ) ) - self.assertEqual(str(context.exception), "id cannot be empty") + assert str(exc_info.value) == "id cannot be empty" - def test_update_empty_name(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.update( - id="mk1", - name="", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, + async def test_update_empty_name(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke( + client.mgmt.management_key.update( + id="mk1", + name="", + description="updated key", + permitted_ips=["1.2.3.4"], + status=MgmtKeyStatus.INACTIVE, + ) ) - self.assertEqual(str(context.exception), "name cannot be empty") + assert str(exc_info.value) == "name cannot be empty" - def test_update_none_status(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.update( - id="mk1", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=None, + async def test_update_none_status(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke( + client.mgmt.management_key.update( + id="mk1", + name="updated-key", + description="updated key", + permitted_ips=["1.2.3.4"], + status=None, + ) ) - self.assertEqual(str(context.exception), "status cannot be empty") + assert str(exc_info.value) == "status cannot be empty" - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "key": { - "id": "mk1", - "name": "updated-key", - "description": "updated key", - "permittedIps": ["1.2.3.4"], - "status": "inactive", - "createdTime": 1764673442, - "expireTime": 0, - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], + with client.mock_mgmt_patch( + make_response( + { + "key": { + "id": "mk1", + "name": "updated-key", + "description": "updated key", + "permittedIps": ["1.2.3.4"], + "status": "inactive", + "createdTime": 1764673442, + "expireTime": 0, + "reBac": { + "companyRoles": [], + "projectRoles": [], + "tagRoles": [], + }, + "version": 22, + "authzVersion": 1, }, - "version": 22, - "authzVersion": 1, - }, - } - mock_patch.return_value = network_resp - resp = client.mgmt.management_key.update( - id="mk1", - name="updated-key", - description="updated key", - permitted_ips=["1.2.3.4"], - status=MgmtKeyStatus.INACTIVE, + } + ) + ) as mock_patch: + resp = await client.invoke( + client.mgmt.management_key.update( + id="mk1", + name="updated-key", + description="updated key", + permitted_ips=["1.2.3.4"], + status=MgmtKeyStatus.INACTIVE, + ) ) key = resp["key"] - self.assertEqual(key["id"], "mk1") - self.assertEqual(key["name"], "updated-key") - self.assertEqual(key["description"], "updated key") - self.assertEqual(len(key["permittedIps"]), 1) - self.assertEqual(key["permittedIps"][0], "1.2.3.4") - self.assertEqual(key["status"], "inactive") - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_update_path}", + assert key["id"] == "mk1" + assert key["name"] == "updated-key" + assert key["description"] == "updated key" + assert len(key["permittedIps"]) == 1 + assert key["permittedIps"][0] == "1.2.3.4" + assert key["status"] == "inactive" + assert_http_called( + mock_patch, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -301,180 +273,149 @@ def test_update(self): "status": "inactive", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_empty_id(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.load("") - self.assertEqual(str(context.exception), "id cannot be empty") + async def test_load_empty_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke(client.mgmt.management_key.load("")) + assert str(exc_info.value) == "id cannot be empty" - def test_load(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "key": { - "id": "mk1", - "name": "test-key", - "description": "a key description", - "status": "active", - "createdTime": 1764677065, - "expireTime": 0, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], + with client.mock_mgmt_get( + make_response( + { + "key": { + "id": "mk1", + "name": "test-key", + "description": "a key description", + "status": "active", + "createdTime": 1764677065, + "expireTime": 0, + "permittedIps": [], + "reBac": { + "companyRoles": [], + "projectRoles": [], + "tagRoles": [], + }, + "version": 1, + "authzVersion": 1, }, - "version": 1, - "authzVersion": 1, - }, - } - mock_get.return_value = network_resp - resp = client.mgmt.management_key.load("mk1") + } + ) + ) as mock_get: + resp = await client.invoke(client.mgmt.management_key.load("mk1")) key = resp["key"] - self.assertIsNotNone(key) - self.assertEqual(key["name"], "test-key") - self.assertEqual(key["description"], "a key description") - self.assertEqual(key["status"], "active") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_load_path}", + assert key is not None + assert key["name"] == "test-key" + assert key["description"] == "a key description" + assert key["status"] == "active" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"id": "mk1"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_empty_ids(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) - with self.assertRaises(ValueError) as context: - client.mgmt.management_key.delete([]) - self.assertEqual(str(context.exception), "ids list cannot be empty") + async def test_delete_empty_ids(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + with pytest.raises(ValueError) as exc_info: + await client.invoke(client.mgmt.management_key.delete([])) + assert str(exc_info.value) == "ids list cannot be empty" - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = {"total": 2} - mock_post.return_value = network_resp - resp = client.mgmt.management_key.delete(["mk1", "mk2"]) - self.assertEqual(resp["total"], 2) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_delete_path}", - params=None, - json={"ids": ["mk1", "mk2"]}, + with client.mock_mgmt_post(make_response({"total": 2})) as mock_post: + resp = await client.invoke(client.mgmt.management_key.delete(["mk1", "mk2"])) + assert resp["total"] == 2 + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, + params=None, + json={"ids": ["mk1", "mk2"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search(self): - client = DescopeClient( - self.dummy_project_id, - None, - False, - self.dummy_management_key, - ) + async def test_search(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "keys": [ - { - "id": "mk1", - "name": "key1", - "description": "", - "status": "active", - "createdTime": 1764677065, - "expireTime": 0, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], + with client.mock_mgmt_get( + make_response( + { + "keys": [ + { + "id": "mk1", + "name": "key1", + "description": "", + "status": "active", + "createdTime": 1764677065, + "expireTime": 0, + "permittedIps": [], + "reBac": { + "companyRoles": [], + "projectRoles": [], + "tagRoles": [], + }, + "version": 1, + "authzVersion": 1, }, - "version": 1, - "authzVersion": 1, - }, - { - "id": "mk2", - "name": "key2", - "description": "", - "status": "inactive", - "createdTime": 1764773205, - "expireTime": 1234, - "permittedIps": [], - "reBac": { - "companyRoles": [], - "projectRoles": [], - "tagRoles": [], + { + "id": "mk2", + "name": "key2", + "description": "", + "status": "inactive", + "createdTime": 1764773205, + "expireTime": 1234, + "permittedIps": [], + "reBac": { + "companyRoles": [], + "projectRoles": [], + "tagRoles": [], + }, + "version": 1, + "authzVersion": 1, }, - "version": 1, - "authzVersion": 1, - }, - ], - } - mock_get.return_value = network_resp - resp = client.mgmt.management_key.search() + ], + } + ) + ) as mock_get: + resp = await client.invoke(client.mgmt.management_key.search()) keys = resp["keys"] - self.assertIsNotNone(keys) - self.assertEqual(len(keys), 2) - self.assertEqual(keys[0]["id"], "mk1") - self.assertEqual(keys[0]["name"], "key1") - self.assertEqual(keys[0]["status"], "active") - self.assertEqual(keys[1]["id"], "mk2") - self.assertEqual(keys[1]["name"], "key2") - self.assertEqual(keys[1]["status"], "inactive") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.mgmt_key_search_path}", + assert keys is not None + assert len(keys) == 2 + assert keys[0]["id"] == "mk1" + assert keys[0]["name"] == "key1" + assert keys[0]["status"] == "active" + assert keys[1]["id"] == "mk2" + assert keys[1]["name"] == "key2" + assert keys[1]["status"] == "inactive" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.mgmt_key_search_path}", headers={ - **common.default_headers, - "x-descope-project-id": self.dummy_project_id, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py index 7daa507c0..341edd245 100644 --- a/tests/management/test_outbound_application.py +++ b/tests/management/test_outbound_application.py @@ -1,68 +1,55 @@ -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.management.common import AccessType, PromptType, URLParam +from descope import AuthException +from descope.management.common import AccessType, MgmtV1, PromptType, URLParam from descope.management.outbound_application import OutboundApplication -from .. import common - - -class TestOutboundApplication(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create_application_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "app": { - "id": "app123", - "name": "Test App", - "description": "Test Description", - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.create_application( - "Test App", description="Test Description", client_secret="secret" +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + +DUMMY_TOKEN = "inbound-app-token" + +APP_RESPONSE = { + "app": { + "id": "app123", + "name": "Test App", + "description": "Test Description", + } +} + +TOKEN_RESPONSE = { + "token": { + "token": "access-token", + "refreshToken": "refresh-token", + "expiresIn": 3600, + "tokenType": "Bearer", + "scopes": ["read", "write"], + } +} + +MGMT_HEADERS = { + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, +} + + +class TestOutboundApplication: + async def test_create_application_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(APP_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.create_application( + "Test App", description="Test Description", client_secret="secret" + ) ) + assert response == APP_RESPONSE - assert response == { - "app": { - "id": "app123", - "name": "Test App", - "description": "Test Description", - } - } - - def test_create_application_with_all_parameters_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_application_with_all_parameters_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Create test data for all new parameters auth_params = [ URLParam("response_type", "code"), URLParam("client_id", "test-client"), @@ -70,38 +57,39 @@ def test_create_application_with_all_parameters_success(self): token_params = [URLParam("grant_type", "authorization_code")] prompts = [PromptType.LOGIN, PromptType.CONSENT] - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "app": { - "id": "app123", - "name": "Test OAuth App", - "description": "Test Description", + with client.mock_mgmt_post( + make_response( + { + "app": { + "id": "app123", + "name": "Test OAuth App", + "description": "Test Description", + } } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.create_application( - name="Test OAuth App", - description="Test Description", - logo="https://example.com/logo.png", - id="app123", - client_secret="secret", - client_id="test-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], - default_redirect_url="https://myapp.com/callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=prompts, ) - + ) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.create_application( + name="Test OAuth App", + description="Test Description", + logo="https://example.com/logo.png", + id="app123", + client_secret="secret", + client_id="test-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=["https://www.googleapis.com/auth/userinfo.profile"], + default_redirect_url="https://myapp.com/callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=prompts, + ) + ) assert response == { "app": { "id": "app123", @@ -110,48 +98,37 @@ def test_create_application_with_all_parameters_success(self): } } - def test_create_application_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.create_application, - "Test App", - ) - - def test_update_application_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "app": { - "id": "app123", - "name": "Updated App", - "description": "Updated Description", + async def test_create_application_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.create_application("Test App") + ) + + async def test_update_application_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post( + make_response( + { + "app": { + "id": "app123", + "name": "Updated App", + "description": "Updated Description", + } } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.update_application( - "app123", - "Updated App", - description="Updated Description", - client_secret="new-secret", ) - + ) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.update_application( + "app123", + "Updated App", + description="Updated Description", + client_secret="new-secret", + ) + ) assert response == { "app": { "id": "app123", @@ -160,15 +137,9 @@ def test_update_application_success(self): } } - def test_update_application_with_all_parameters_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_application_with_all_parameters_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Create test data for all new parameters auth_params = [ URLParam("response_type", "code"), URLParam("client_id", "test-client"), @@ -176,41 +147,42 @@ def test_update_application_with_all_parameters_success(self): token_params = [URLParam("grant_type", "authorization_code")] prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "app": { - "id": "app123", - "name": "Updated OAuth App", - "description": "Updated Description", + with client.mock_mgmt_post( + make_response( + { + "app": { + "id": "app123", + "name": "Updated OAuth App", + "description": "Updated Description", + } } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.update_application( - id="app123", - name="Updated OAuth App", - description="Updated Description", - logo="https://example.com/new-logo.png", - client_secret="new-secret", - client_id="new-client-id", - discovery_url="https://accounts.google.com/.well-known/openid_configuration", - authorization_url="https://accounts.google.com/o/oauth2/v2/auth", - authorization_url_params=auth_params, - token_url="https://oauth2.googleapis.com/token", - token_url_params=token_params, - revocation_url="https://oauth2.googleapis.com/revoke", - default_scopes=[ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - ], - default_redirect_url="https://myapp.com/updated-callback", - callback_domain="myapp.com", - pkce=True, - access_type=AccessType.OFFLINE, - prompt=prompts, ) - + ) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.update_application( + id="app123", + name="Updated OAuth App", + description="Updated Description", + logo="https://example.com/new-logo.png", + client_secret="new-secret", + client_id="new-client-id", + discovery_url="https://accounts.google.com/.well-known/openid_configuration", + authorization_url="https://accounts.google.com/o/oauth2/v2/auth", + authorization_url_params=auth_params, + token_url="https://oauth2.googleapis.com/token", + token_url_params=token_params, + revocation_url="https://oauth2.googleapis.com/revoke", + default_scopes=[ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + ], + default_redirect_url="https://myapp.com/updated-callback", + callback_domain="myapp.com", + pkce=True, + access_type=AccessType.OFFLINE, + prompt=prompts, + ) + ) assert response == { "app": { "id": "app123", @@ -219,353 +191,181 @@ def test_update_application_with_all_parameters_success(self): } } - def test_update_application_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_application_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.update_application, - "app123", - "Updated App", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.update_application( + "app123", "Updated App" + ) + ) - def test_delete_application_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_application_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - client.mgmt.outbound_application.delete_application("app123") - - def test_delete_application_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.delete_application, - "app123", + with client.mock_mgmt_post(make_response(status=200)): + await client.invoke( + client.mgmt.outbound_application.delete_application("app123") ) - def test_load_application_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_application_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "app": { - "id": "app123", - "name": "Test App", - "description": "Test Description", - } - } - mock_get.return_value = network_resp - response = client.mgmt.outbound_application.load_application("app123") - - assert response == { - "app": { - "id": "app123", - "name": "Test App", - "description": "Test Description", - } - } + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.delete_application("app123") + ) - def test_load_application_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_application_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.load_application, - "app123", + with client.mock_mgmt_get(make_response(APP_RESPONSE)) as mock_get: + response = await client.invoke( + client.mgmt.outbound_application.load_application("app123") ) - - def test_load_all_applications_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "apps": [ - {"id": "app1", "name": "App 1", "description": "Description 1"}, - {"id": "app2", "name": "App 2", "description": "Description 2"}, - ] - } - mock_get.return_value = network_resp - response = client.mgmt.outbound_application.load_all_applications() - - assert response == { - "apps": [ - {"id": "app1", "name": "App 1", "description": "Description 1"}, - {"id": "app2", "name": "App 2", "description": "Description 2"}, - ] - } - - def test_load_all_applications_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.load_all_applications, + assert response == APP_RESPONSE + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_path}/app123", + headers=MGMT_HEADERS, + params=None, + follow_redirects=True, ) - def test_fetch_token_by_scopes_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_application_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.fetch_token_by_scopes( - "app123", - "user456", - ["read", "write"], - {"refreshToken": True}, - "tenant789", - ) - - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.load_application("app123") + ) - def test_fetch_token_by_scopes_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all_applications_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.fetch_token_by_scopes, - "app123", - "user456", - ["read"], + apps_response = { + "apps": [ + {"id": "app1", "name": "App 1", "description": "Description 1"}, + {"id": "app2", "name": "App 2", "description": "Description 2"}, + ] + } + with client.mock_mgmt_get(make_response(apps_response)) as mock_get: + response = await client.invoke( + client.mgmt.outbound_application.load_all_applications() ) - - def test_fetch_token_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.fetch_token( - "app123", "user456", "tenant789", {"forceRefresh": True} + assert response == apps_response + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_load_all_path}", + headers=MGMT_HEADERS, + params=None, + follow_redirects=True, ) - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_token_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.fetch_token, - "app123", - "user456", + async def test_load_all_applications_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.load_all_applications() + ) + + async def test_fetch_token_by_scopes_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.fetch_token_by_scopes( + "app123", + "user456", + ["read", "write"], + {"refreshToken": True}, + "tenant789", + ) ) - - def test_fetch_tenant_token_by_scopes_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.fetch_tenant_token_by_scopes( - "app123", "tenant789", ["read", "write"], {"refreshToken": True} + assert response == TOKEN_RESPONSE + + async def test_fetch_token_by_scopes_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.fetch_token_by_scopes( + "app123", "user456", ["read"] + ) + ) + + async def test_fetch_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.fetch_token( + "app123", "user456", "tenant789", {"forceRefresh": True} + ) ) + assert response == TOKEN_RESPONSE - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_tenant_token_by_scopes_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_fetch_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.fetch_tenant_token_by_scopes, - "app123", - "tenant789", - ["read"], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.fetch_token("app123", "user456") + ) - def test_fetch_tenant_token_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_fetch_tenant_token_by_scopes_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application.fetch_tenant_token( - "app123", "tenant789", {"forceRefresh": True} + with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.fetch_tenant_token_by_scopes( + "app123", "tenant789", ["read", "write"], {"refreshToken": True} + ) ) + assert response == TOKEN_RESPONSE + + async def test_fetch_tenant_token_by_scopes_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.fetch_tenant_token_by_scopes( + "app123", "tenant789", ["read"] + ) + ) + + async def test_fetch_tenant_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application.fetch_tenant_token( + "app123", "tenant789", {"forceRefresh": True} + ) + ) + assert response == TOKEN_RESPONSE - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_tenant_token_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_fetch_tenant_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.fetch_tenant_token, - "app123", - "tenant789", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.fetch_tenant_token( + "app123", "tenant789" + ) + ) def test_compose_create_update_body(self): body = OutboundApplication._compose_create_update_body( @@ -601,7 +401,6 @@ def test_compose_create_update_body_without_client_secret(self): assert body == expected_body def test_compose_create_update_body_with_all_new_parameters(self): - # Create test data for all new parameters auth_params = [ URLParam("response_type", "code"), URLParam("client_id", "test-client"), @@ -657,7 +456,6 @@ def test_compose_create_update_body_with_all_new_parameters(self): assert body == expected_body def test_compose_create_update_body_with_partial_new_parameters(self): - # Test with only some of the new parameters body = OutboundApplication._compose_create_update_body( name="Test App", description="Test Description", @@ -685,7 +483,6 @@ def test_compose_create_update_body_with_partial_new_parameters(self): assert body == expected_body def test_compose_create_update_body_with_url_params_only(self): - # Test with only URL parameters auth_params = [URLParam("response_type", "code")] token_params = [URLParam("grant_type", "authorization_code")] @@ -708,7 +505,6 @@ def test_compose_create_update_body_with_url_params_only(self): assert body == expected_body def test_compose_create_update_body_with_prompt_types(self): - # Test with different prompt type combinations prompts = [PromptType.LOGIN, PromptType.CONSENT, PromptType.SELECT_ACCOUNT] body = OutboundApplication._compose_create_update_body( @@ -726,13 +522,12 @@ def test_compose_create_update_body_with_prompt_types(self): assert body == expected_body def test_compose_create_update_body_with_none_values(self): - # Test that None values are handled correctly body = OutboundApplication._compose_create_update_body( name="Test App", description="Test Description", - pkce=None, # Should not be included in body - access_type=None, # Should not be included in body - prompt=None, # Should not be included in body + pkce=None, + access_type=None, + prompt=None, ) expected_body = { @@ -745,7 +540,6 @@ def test_compose_create_update_body_with_none_values(self): assert body == expected_body def test_compose_create_update_body_with_empty_lists(self): - # Test with empty lists for URL parameters and prompts body = OutboundApplication._compose_create_update_body( name="Test App", description="Test Description", @@ -768,116 +562,93 @@ def test_compose_create_update_body_with_empty_lists(self): assert body == expected_body - def test_delete_user_tokens_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_user_tokens_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - mock_delete.return_value = network_resp - client.mgmt.outbound_application.delete_user_tokens(app_id="app123", user_id="user456") - - mock_delete.assert_called_once() - call_args = mock_delete.call_args - assert call_args[1]["params"]["appId"] == "app123" - assert call_args[1]["params"]["userId"] == "user456" - - def test_delete_user_tokens_with_app_id_only(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: + await client.invoke( + client.mgmt.outbound_application.delete_user_tokens( + app_id="app123", user_id="user456" + ) + ) + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", + headers=MGMT_HEADERS, + params={"appId": "app123", "userId": "user456"}, + follow_redirects=False, + ) - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - mock_delete.return_value = network_resp - client.mgmt.outbound_application.delete_user_tokens(app_id="app123") - - mock_delete.assert_called_once() - call_args = mock_delete.call_args - assert call_args[1]["params"]["appId"] == "app123" - assert "userId" not in call_args[1]["params"] - - def test_delete_user_tokens_with_user_id_only(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_user_tokens_with_app_id_only(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - mock_delete.return_value = network_resp - client.mgmt.outbound_application.delete_user_tokens(user_id="user456") - - mock_delete.assert_called_once() - call_args = mock_delete.call_args - assert call_args[1]["params"]["userId"] == "user456" - assert "appId" not in call_args[1]["params"] - - def test_delete_user_tokens_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: + await client.invoke( + client.mgmt.outbound_application.delete_user_tokens(app_id="app123") + ) + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", + headers=MGMT_HEADERS, + params={"appId": "app123"}, + follow_redirects=False, + ) + + async def test_delete_user_tokens_with_user_id_only(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.delete_user_tokens, - "app123", - "user456", + with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: + await client.invoke( + client.mgmt.outbound_application.delete_user_tokens(user_id="user456") + ) + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_user_tokens_path}", + headers=MGMT_HEADERS, + params={"userId": "user456"}, + follow_redirects=False, ) - def test_delete_token_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_user_tokens_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - mock_delete.return_value = network_resp - client.mgmt.outbound_application.delete_token("token123") - - mock_delete.assert_called_once() - call_args = mock_delete.call_args - assert call_args[1]["params"]["id"] == "token123" - - def test_delete_token_failure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + with client.mock_mgmt_delete(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.delete_user_tokens( + app_id="app123", user_id="user456" + ) + ) + + async def test_delete_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application.delete_token, - "token123", + with client.mock_mgmt_delete(make_response(status=200)) as mock_delete: + await client.invoke( + client.mgmt.outbound_application.delete_token("token123") + ) + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.outbound_application_delete_token_path}", + headers=MGMT_HEADERS, + params={"id": "token123"}, + follow_redirects=False, ) + async def test_delete_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_delete(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application.delete_token("token123") + ) + def test_url_param_to_dict(self): - # Test URLParam to_dict method param = URLParam("test_name", "test_value") param_dict = param.to_dict() @@ -885,266 +656,170 @@ def test_url_param_to_dict(self): assert param_dict == expected_dict def test_access_type_enum_values(self): - # Test AccessType enum values assert AccessType.OFFLINE.value == "offline" assert AccessType.ONLINE.value == "online" def test_prompt_type_enum_values(self): - # Test PromptType enum values assert PromptType.NONE.value == "none" assert PromptType.LOGIN.value == "login" assert PromptType.CONSENT.value == "consent" assert PromptType.SELECT_ACCOUNT.value == "select_account" -class TestOutboundApplicationByToken(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_token = "inbound-app-token" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_fetch_token_by_scopes_success(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application_by_token.fetch_token_by_scopes( - self.dummy_token, - "app123", - "user456", - ["read", "write"], - {"refreshToken": True}, - "tenant789", - ) - - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_token_by_scopes_failure(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - # Test failure of empty token - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_token_by_scopes, - "", # empty token - "app123", - "user456", - ["read"], - ) - - # Test invalid response failure - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_token_by_scopes, - self.dummy_token, - "app123", - "user456", - ["read"], - ) - - def test_fetch_token_success(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application_by_token.fetch_token( - self.dummy_token, - "app123", - "user456", - "tenant789", - {"forceRefresh": True}, +class TestOutboundApplicationByToken: + async def test_fetch_token_by_scopes_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + DUMMY_TOKEN, + "app123", + "user456", + ["read", "write"], + {"refreshToken": True}, + "tenant789", + ) ) - - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_token_failure(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - # Test failure of empty token - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_token, - "", # empty token - "app123", - "user456", + assert response == TOKEN_RESPONSE + + async def test_fetch_token_by_scopes_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + # Empty token should raise AuthException immediately (no HTTP call needed) + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + "", + "app123", + "user456", + ["read"], + ) ) - # Test invalid response failure - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_token, - self.dummy_token, - "app123", - "user456", + # Invalid response failure + with client.mock_mgmt_by_token_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token_by_scopes( + DUMMY_TOKEN, + "app123", + "user456", + ["read"], + ) + ) + + async def test_fetch_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token( + DUMMY_TOKEN, + "app123", + "user456", + "tenant789", + {"forceRefresh": True}, + ) ) - - def test_fetch_tenant_token_by_scopes_success(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( - self.dummy_token, - "app123", - "tenant789", - ["read", "write"], - {"refreshToken": True}, + assert response == TOKEN_RESPONSE + + async def test_fetch_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + # Empty token should raise AuthException immediately + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token( + "", + "app123", + "user456", + ) ) - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_tenant_token_by_scopes_failure(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - # Test failure of empty token - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes, - "", # empty token - "app123", - "tenant789", - ["read"], + # Invalid response failure + with client.mock_mgmt_by_token_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_token( + DUMMY_TOKEN, + "app123", + "user456", + ) + ) + + async def test_fetch_tenant_token_by_scopes_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( + DUMMY_TOKEN, + "app123", + "tenant789", + ["read", "write"], + {"refreshToken": True}, + ) ) - - # Test invalid response failure - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes, - self.dummy_token, - "app123", - "tenant789", - ["read"], + assert response == TOKEN_RESPONSE + + async def test_fetch_tenant_token_by_scopes_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + # Empty token should raise AuthException immediately + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( + "", + "app123", + "tenant789", + ["read"], + ) ) - def test_fetch_tenant_token_success(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - mock_post.return_value = network_resp - response = client.mgmt.outbound_application_by_token.fetch_tenant_token( - self.dummy_token, "app123", "tenant789", {"forceRefresh": True} + # Invalid response failure + with client.mock_mgmt_by_token_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token_by_scopes( + DUMMY_TOKEN, + "app123", + "tenant789", + ["read"], + ) + ) + + async def test_fetch_tenant_token_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + with client.mock_mgmt_by_token_post(make_response(TOKEN_RESPONSE)) as mock_post: + response = await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token( + DUMMY_TOKEN, "app123", "tenant789", {"forceRefresh": True} + ) ) - - assert response == { - "token": { - "token": "access-token", - "refreshToken": "refresh-token", - "expiresIn": 3600, - "tokenType": "Bearer", - "scopes": ["read", "write"], - } - } - - def test_fetch_tenant_token_failure(self): - client = DescopeClient(self.dummy_project_id, self.public_key_dict, False) - - # Test failure of empty token - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_tenant_token, - "", # empty token - "app123", - "tenant789", + assert response == TOKEN_RESPONSE + + async def test_fetch_tenant_token_failure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False) + + # Empty token should raise AuthException immediately + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token( + "", + "app123", + "tenant789", + ) ) - # Test invalid response failure - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.outbound_application_by_token.fetch_tenant_token, - self.dummy_token, - "app123", - "tenant789", - ) + # Invalid response failure + with client.mock_mgmt_by_token_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.outbound_application_by_token.fetch_tenant_token( + DUMMY_TOKEN, + "app123", + "tenant789", + ) + ) diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index 035d916a4..e7a92b7c1 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -1,57 +1,33 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestPermission(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + + +class TestPermission: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.create, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.create("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.create("P1", "Something")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_create_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.permission.create("P1", "Something")) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -59,44 +35,31 @@ def test_create(self): "description": "Something", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.update, - "name", - "new-name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.update("name", "new-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.permission.update( - "name", - "new-name", - "new-description", + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke( + client.mgmt.permission.update("name", "new-name", "new-description") ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -105,38 +68,31 @@ def test_update(self): "description": "new-description", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_by_id(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_by_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.update_by_id, - "PERM123", - "new-name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.update_by_id("PERM123", "new-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.update_by_id("PERM123", "new-name", "new-description")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke( + client.mgmt.permission.update_by_id("PERM123", "new-name", "new-description") + ) + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -145,116 +101,88 @@ def test_update_by_id(self): "description": "new-description", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.delete, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.delete("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete("name")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.permission.delete("name")) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "name": "name", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_by_id(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_by_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.delete_by_id, - "PERM123", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.delete_by_id("PERM123")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete_by_id("PERM123")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.permission.delete_by_id("PERM123")) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"id": "PERM123"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.create_batch, - [{"name": "P1"}], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.create_batch([{"name": "P1"}])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.permission.create_batch( - [ - {"name": "P1", "description": "desc1"}, - {"name": "P2"}, - ] + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke( + client.mgmt.permission.create_batch( + [ + {"name": "P1", "description": "desc1"}, + {"name": "P2"}, + ] + ) ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_create_batch_path}", + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_create_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -264,44 +192,40 @@ def test_create_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.update_batch, - [{"name": "P1", "newName": "P1-new"}], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.permission.update_batch( + [{"name": "P1", "newName": "P1-new"}] + ) + ) # Test success flow — by name - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.permission.update_batch( - [ - {"name": "P1", "newName": "P1-new", "description": "d1"}, - {"name": "P2", "newName": "P2-new"}, - ] + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke( + client.mgmt.permission.update_batch( + [ + {"name": "P1", "newName": "P1-new", "description": "d1"}, + {"name": "P2", "newName": "P2-new"}, + ] + ) ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -311,27 +235,28 @@ def test_update_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow — by id - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.permission.update_batch( - [ - {"id": "PERM1", "newName": "P1-new", "description": "d1"}, - {"id": "PERM2", "newName": "P2-new"}, - ] + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke( + client.mgmt.permission.update_batch( + [ + {"id": "PERM1", "newName": "P1-new", "description": "d1"}, + {"id": "PERM2", "newName": "P2-new"}, + ] + ) ) - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_update_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -341,113 +266,86 @@ def test_update_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.delete_batch, - ["P1"], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.delete_batch(["P1"])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete_batch(["P1", "P2"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.permission.delete_batch(["P1", "P2"])) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"names": ["P1", "P2"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_batch_by_ids(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_batch_by_ids(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.permission.delete_batch_by_ids, - ["PERM1"], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.delete_batch_by_ids(["PERM1"])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete_batch_by_ids(["PERM1", "PERM2"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert ( + await client.invoke(client.mgmt.permission.delete_batch_by_ids(["PERM1", "PERM2"])) + ) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"ids": ["PERM1", "PERM2"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.permission.load_all) + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.permission.load_all()) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"permissions": [{"name": "p1"}, {"name": "p2"}]}""") - mock_get.return_value = network_resp - resp = client.mgmt.permission.load_all() + with client.mock_mgmt_get( + make_response({"permissions": [{"name": "p1"}, {"name": "p2"}]}) + ) as mock_get: + resp = await client.invoke(client.mgmt.permission.load_all()) permissions = resp["permissions"] - self.assertEqual(len(permissions), 2) - self.assertEqual(permissions[0]["name"], "p1") - self.assertEqual(permissions[1]["name"], "p2") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_load_all_path}", + assert len(permissions) == 2 + assert permissions[0]["name"] == "p1" + assert permissions[1]["name"] == "p2" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.permission_load_all_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_project.py b/tests/management/test_project.py index 88fca6ef3..dd1bea549 100644 --- a/tests/management/test_project.py +++ b/tests/management/test_project.py @@ -1,199 +1,138 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestProject(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_update_name(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestProject: + async def test_update_name(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.update_name, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.update_name("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.project.update_name("new-name")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_update_name}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.project.update_name("new-name")) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_update_name}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "name": "new-name", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_tags(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_tags(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.update_tags, - "tags", - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.update_tags("tags")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.project.update_tags(["tag1", "tag2"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_update_tags}", + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.project.update_tags(["tag1", "tag2"])) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_update_tags}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "tags": ["tag1", "tag2"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_list_projects(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_list_projects(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.list_projects, - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.list_projects()) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - json_str = """ - { - "projects": [ - { - "id": "dummy", - "name": "hey", - "environment": "", - "tags": ["tag1", "tag2"] - } - ] - } - """ - network_resp.json.return_value = json.loads(json_str) - mock_post.return_value = network_resp - resp = client.mgmt.project.list_projects() + json_data = { + "projects": [ + { + "id": "dummy", + "name": "hey", + "environment": "", + "tags": ["tag1", "tag2"], + } + ] + } + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke(client.mgmt.project.list_projects()) projects = resp["projects"] - self.assertEqual(len(projects), 1) - self.assertEqual(projects[0]["id"], "dummy") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_list_projects}", + assert len(projects) == 1 + assert projects[0]["id"] == "dummy" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_list_projects}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_clone(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_clone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.clone, - "new-name", - "production", - ["apple", "banana", "cherry"], - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.project.clone( + "new-name", + "production", + ["apple", "banana", "cherry"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - { - "id":"dummy" - } - """ - ) - mock_post.return_value = network_resp - resp = client.mgmt.project.clone( - "new-name", - "production", - ["apple", "banana", "cherry"], + with client.mock_mgmt_post(make_response({"id": "dummy"})) as mock_post: + resp = await client.invoke( + client.mgmt.project.clone( + "new-name", + "production", + ["apple", "banana", "cherry"], + ) ) - self.assertEqual(resp["id"], "dummy") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_clone}", + assert resp["id"] == "dummy" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_clone}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -202,89 +141,55 @@ def test_clone(self): "tags": ["apple", "banana", "cherry"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_export_project(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_export_project(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.export_project, - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.export_project()) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - { - "files":{"foo":"bar"} - } - """ - ) - mock_post.return_value = network_resp - resp = client.mgmt.project.export_project() - self.assertIsNotNone(resp) - self.assertEqual(resp["foo"], "bar") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_export}", + with client.mock_mgmt_post(make_response({"files": {"foo": "bar"}})) as mock_post: + resp = await client.invoke(client.mgmt.project.export_project()) + assert resp is not None + assert resp["foo"] == "bar" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_export}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_import_project(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_import_project(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.project.import_project, - { - "foo": "bar", - }, - ) + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.import_project({"foo": "bar"})) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - files = { - "foo": "bar", - } - client.mgmt.project.import_project(files) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.project_import}", + files = {"foo": "bar"} + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke(client.mgmt.project.import_project(files)) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_import}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -293,6 +198,4 @@ def test_import_project(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 39c679478..95d078d5e 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -1,57 +1,33 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import MgmtV1 -from .. import common -from ..testutils import SSLMatcher - - -class TestRole(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + + +class TestRole: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.create, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.create("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.create("R1", "Something", ["P1"], "t1", True, False)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_create_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.create("R1", "Something", ["P1"], "t1", True, False)) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -63,51 +39,44 @@ def test_create(self): "private": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.create_batch, - [{"name": "R1"}], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.create_batch([{"name": "R1"}])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.create_batch( - [ - { - "name": "R1", - "description": "desc1", - "permissionNames": ["P1"], - "tenantId": "t1", - "default": True, - "private": False, - }, - {"name": "R2"}, - ] + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.create_batch( + [ + { + "name": "R1", + "description": "desc1", + "permissionNames": ["P1"], + "tenantId": "t1", + "default": True, + "private": False, + }, + {"name": "R2"}, + ] + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_create_batch_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_create_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -124,52 +93,45 @@ def test_create_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.update_batch, - [{"name": "R1", "newName": "R1-new"}], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.update_batch([{"name": "R1", "newName": "R1-new"}])) # Test success flow — by name - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update_batch( - [ - { - "name": "R1", - "newName": "R1-new", - "description": "d1", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": False, - "private": True, - }, - {"name": "R2", "newName": "R2-new"}, - ] + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.update_batch( + [ + { + "name": "R1", + "newName": "R1-new", + "description": "d1", + "permissionNames": ["P1", "P2"], + "tenantId": "t1", + "default": False, + "private": True, + }, + {"name": "R2", "newName": "R2-new"}, + ] + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -187,27 +149,29 @@ def test_update_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow — by id - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update_batch( - [ - {"id": "ROL1", "newName": "R1-new", "description": "d1"}, - {"id": "ROL2", "newName": "R2-new"}, - ] + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.update_batch( + [ + {"id": "ROL1", "newName": "R1-new", "description": "d1"}, + {"id": "ROL2", "newName": "R2-new"}, + ] + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_update_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -217,48 +181,40 @@ def test_update_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.update, - "name", - "new-name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.update("name", "new-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update( - "name", - "new-name", - "new-description", - ["P1", "P2"], - "t1", - True, - False, + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.update( + "name", + "new-name", + "new-description", + ["P1", "P2"], + "t1", + True, + False, + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -271,48 +227,40 @@ def test_update(self): "private": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_by_id(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_by_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.update_by_id, - "ROL123", - "new-name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.update_by_id("ROL123", "new-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update_by_id( - "ROL123", - "new-name", - "new-description", - ["P1", "P2"], - "t1", - True, - False, + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.update_by_id( + "ROL123", + "new-name", + "new-description", + ["P1", "P2"], + "t1", + True, + False, + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -325,114 +273,87 @@ def test_update_by_id(self): "private": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.delete, - "name", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.delete("name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete("name")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.delete("name")) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"name": "name", "tenantId": None}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_by_id(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_by_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.delete_by_id, - "ROL123", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.delete_by_id("ROL123")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete_by_id("ROL123", "t1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.delete_by_id("ROL123", "t1")) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"id": "ROL123", "tenantId": "t1"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_batch(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.delete_batch, - [{"name": "R1"}], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.delete_batch([{"name": "R1"}])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.delete_batch( - [ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2"}, - ] + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.delete_batch( + [ + {"name": "R1", "tenantId": "t1"}, + {"name": "R2"}, + ] + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -442,139 +363,110 @@ def test_delete_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_batch_by_ids(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete_batch_by_ids(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.delete_batch_by_ids, - ["ROL1"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.delete_batch_by_ids(["ROL1"])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1")) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"roleIds": ["ROL1", "ROL2"], "tenantId": "t1"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.role.load_all) + with client.mock_mgmt_get(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.load_all()) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads(""" + with client.mock_mgmt_get( + make_response( { "roles": [ {"name": "R1", "permissionNames": ["P1", "P2"]}, - {"name": "R2"} + {"name": "R2"}, ] } - """) - mock_get.return_value = network_resp - resp = client.mgmt.role.load_all() + ) + ) as mock: + resp = await client.invoke(client.mgmt.role.load_all()) roles = resp["roles"] - self.assertEqual(len(roles), 2) - self.assertEqual(roles[0]["name"], "R1") - self.assertEqual(roles[1]["name"], "R2") + assert len(roles) == 2 + assert roles[0]["name"] == "R1" + assert roles[1]["name"] == "R2" permissions = roles[0]["permissionNames"] - self.assertEqual(len(permissions), 2) - self.assertEqual(permissions[0], "P1") - self.assertEqual(permissions[1], "P2") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_load_all_path}", + assert len(permissions) == 2 + assert permissions[0] == "P1" + assert permissions[1] == "P2" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_load_all_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_search(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.role.search, - ["t"], - ["r"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.role.search(["t"], ["r"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads(""" + with client.mock_mgmt_post( + make_response( { "roles": [ {"name": "R1", "permissionNames": ["P1", "P2"]}, - {"name": "R2"} + {"name": "R2"}, ] } - """) - mock_post.return_value = network_resp - resp = client.mgmt.role.search(["t"], ["r"], "x", ["p1", "p2"]) + ) + ) as mock: + resp = await client.invoke(client.mgmt.role.search(["t"], ["r"], "x", ["p1", "p2"])) roles = resp["roles"] - self.assertEqual(len(roles), 2) - self.assertEqual(roles[0]["name"], "R1") - self.assertEqual(roles[1]["name"], "R2") + assert len(roles) == 2 + assert roles[0]["name"] == "R1" + assert roles[1]["name"] == "R2" permissions = roles[0]["permissionNames"] - self.assertEqual(len(permissions), 2) - self.assertEqual(permissions[0], "P1") - self.assertEqual(permissions[1], "P2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_search_path}", + assert len(permissions) == 2 + assert permissions[0] == "P1" + assert permissions[1] == "P2" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -584,60 +476,47 @@ def test_search(self): "permissionNames": ["p1", "p2"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search_by_role_ids(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"roles": [{"id": "ROL123", "name": "R1"}]}""") - mock_post.return_value = network_resp - resp = client.mgmt.role.search(role_ids=["ROL123"]) + async def test_search_by_role_ids(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post( + make_response({"roles": [{"id": "ROL123", "name": "R1"}]}) + ) as mock: + resp = await client.invoke(client.mgmt.role.search(role_ids=["ROL123"])) roles = resp["roles"] - self.assertEqual(len(roles), 1) - self.assertEqual(roles[0]["id"], "ROL123") - self.assertEqual(roles[0]["name"], "R1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_search_path}", + assert len(roles) == 1 + assert roles[0]["id"] == "ROL123" + assert roles[0]["name"] == "R1" + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={"roleIds": ["ROL123"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_with_private_true(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_with_private_true(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test private=True - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.create("PrivateRole", "Private role", ["P1"], "t1", False, True)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_create_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.create("PrivateRole", "Private role", ["P1"], "t1", False, True)) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -649,38 +528,35 @@ def test_create_with_private_true(self): "private": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_with_private_true(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_with_private_true(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test private=True - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update( - "role", - "updated-role", - "Updated private role", - ["P1", "P2"], - "t1", - False, - True, + with client.mock_mgmt_post(make_response()) as mock: + assert ( + await client.invoke( + client.mgmt.role.update( + "role", + "updated-role", + "Updated private role", + ["P1", "P2"], + "t1", + False, + True, + ) ) + is None ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_path}", + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -693,28 +569,22 @@ def test_update_with_private_true(self): "private": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_without_private_parameter(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_create_without_private_parameter(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test without private parameter (should be None) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.create("SimpleRole", "Simple role")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_create_path}", + with client.mock_mgmt_post(make_response()) as mock: + assert await client.invoke(client.mgmt.role.create("SimpleRole", "Simple role")) is None + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.role_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -726,6 +596,4 @@ def test_create_without_private_parameter(self): "private": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_sso_application.py b/tests/management/test_sso_application.py index d9972872a..7700e6457 100644 --- a/tests/management/test_sso_application.py +++ b/tests/management/test_sso_application.py @@ -1,72 +1,49 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest from descope import ( AuthException, - DescopeClient, SAMLIDPAttributeMappingInfo, SAMLIDPGroupsMappingInfo, SAMLIDPRoleGroupMappingInfo, ) -from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT -from .. import common -from ..testutils import SSLMatcher - -class TestSSOApplication(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create_oidc_application(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestSSOApplication: + async def test_create_oidc_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.create_oidc_application, - "valid-name", - "http://dummy.com", - ) + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.create_oidc_application( + "valid-name", + "http://dummy.com", + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"id": "app1"}""") - mock_post.return_value = network_resp - resp = client.mgmt.sso_application.create_oidc_application( - name="name", - login_page_url="http://dummy.com", - force_authentication=True, + with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: + resp = await client.invoke( + client.mgmt.sso_application.create_oidc_application( + name="name", + login_page_url="http://dummy.com", + force_authentication=True, + ) ) - self.assertEqual(resp["id"], "app1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_create_path}", + assert resp["id"] == "app1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -79,82 +56,77 @@ def test_create_oidc_application(self): "forceAuthentication": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_saml_application(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - self.assertRaises( - Exception, - client.mgmt.sso_application.create_saml_application, - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="", - ) + async def test_create_saml_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - self.assertRaises( - Exception, - client.mgmt.sso_application.create_saml_application, - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=False, - entity_id="", - ) + # Test failed flows — validation errors (no HTTP call needed) + with pytest.raises(Exception): + await client.invoke( + client.mgmt.sso_application.create_saml_application( + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=True, + metadata_url="", + ) + ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.create_saml_application, - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", + with pytest.raises(Exception): + await client.invoke( + client.mgmt.sso_application.create_saml_application( + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=False, + entity_id="", + ) ) - # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"id": "app1"}""") - mock_post.return_value = network_resp - resp = client.mgmt.sso_application.create_saml_application( - name="name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", - attribute_mapping=[SAMLIDPAttributeMappingInfo("name1", "type1", "val1")], - groups_mapping=[ - SAMLIDPGroupsMappingInfo( - "name1", - "type1", - "roles", - "val1", - [SAMLIDPRoleGroupMappingInfo("id1", "name1")], + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.create_saml_application( + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=True, + metadata_url="http://dummy.com/md", ) - ], - subject_name_id_type="email", - default_relay_state="relayState", - force_authentication=True, - logout_redirect_url="http://dummy.com/logout", - default_signature_algorithm="sha256", + ) + + # Test success flow + with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: + resp = await client.invoke( + client.mgmt.sso_application.create_saml_application( + name="name", + login_page_url="http://dummy.com", + use_metadata_info=True, + metadata_url="http://dummy.com/md", + attribute_mapping=[SAMLIDPAttributeMappingInfo("name1", "type1", "val1")], + groups_mapping=[ + SAMLIDPGroupsMappingInfo( + "name1", + "type1", + "roles", + "val1", + [SAMLIDPRoleGroupMappingInfo("id1", "name1")], + ) + ], + subject_name_id_type="email", + default_relay_state="relayState", + force_authentication=True, + logout_redirect_url="http://dummy.com/logout", + default_signature_algorithm="sha256", + ) ) - self.assertEqual(resp["id"], "app1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_create_path}", + assert resp["id"] == "app1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -188,40 +160,40 @@ def test_create_saml_application(self): "defaultSignatureAlgorithm": "sha256", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_oidc_application(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_oidc_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.update_oidc_application, - "id1", - "valid-name", - "http://dummy.com", - ) + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.update_oidc_application( + "id1", + "valid-name", + "http://dummy.com", + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - self.assertIsNone(client.mgmt.sso_application.update_oidc_application("app1", "name", "http://dummy.com")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_update_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( + client.mgmt.sso_application.update_oidc_application( + "app1", + "name", + "http://dummy.com", + ) + ) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_oidc_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -234,56 +206,49 @@ def test_update_oidc_application(self): "forceAuthentication": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_saml_application(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_update_saml_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed flows - self.assertRaises( - Exception, - client.mgmt.sso_application.update_saml_application, - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="", - ) - - self.assertRaises( - Exception, - client.mgmt.sso_application.update_saml_application, - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=False, - entity_id="", - ) + # Test failed flows — validation errors (no HTTP call needed) + with pytest.raises(Exception): + await client.invoke( + client.mgmt.sso_application.update_saml_application( + id="id1", + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=True, + metadata_url="", + ) + ) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.update_saml_application, - id="id1", - name="valid-name", - login_page_url="http://dummy.com", - use_metadata_info=True, - metadata_url="http://dummy.com/md", + with pytest.raises(Exception): + await client.invoke( + client.mgmt.sso_application.update_saml_application( + id="id1", + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=False, + entity_id="", + ) ) + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.update_saml_application( + id="id1", + name="valid-name", + login_page_url="http://dummy.com", + use_metadata_info=True, + metadata_url="http://dummy.com/md", + ) + ) + # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso_application.update_saml_application( id="id1", name="name", @@ -306,12 +271,15 @@ def test_update_saml_application(self): subject_name_id_type="", ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_update_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_saml_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -345,87 +313,64 @@ def test_update_saml_application(self): "defaultSignatureAlgorithm": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.delete, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.delete("valid-id") + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.sso_application.delete("app1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_delete_path}", + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.sso_application.delete("app1")) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "id": "app1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso_application.load, - "valid-id", - ) + with client.mock_mgmt_get(make_response(status=400)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso_application.load("valid-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - {"id":"app1","name":"App1","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"http://dummy.com/login","idpCert":"cert","useMetadataInfo":true,"metadataUrl":"http://dummy.com/md","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":""}} - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.sso_application.load("app1") - self.assertEqual(resp["name"], "App1") - self.assertEqual(resp["appType"], "saml") - self.assertEqual(resp["samlSettings"]["loginPageUrl"], "http://dummy.com/login") - self.assertEqual(resp["samlSettings"]["useMetadataInfo"], True) - self.assertEqual(resp["samlSettings"]["metadataUrl"], "http://dummy.com/md") - self.assertEqual( - resp["samlSettings"]["attributeMapping"], - [{"name": "email", "type": "", "value": "attrVal1"}], - ) - self.assertEqual( - resp["samlSettings"]["groupsMapping"], - [ + load_resp = { + "id": "app1", + "name": "App1", + "description": "", + "enabled": True, + "logo": "", + "appType": "saml", + "samlSettings": { + "loginPageUrl": "http://dummy.com/login", + "idpCert": "cert", + "useMetadataInfo": True, + "metadataUrl": "http://dummy.com/md", + "entityId": "", + "acsUrl": "", + "certificate": "", + "attributeMapping": [{"name": "email", "type": "", "value": "attrVal1"}], + "groupsMapping": [ { "name": "grp1", "type": "", @@ -434,87 +379,159 @@ def test_load(self): "roles": [{"id": "myRoleId", "name": "myRole"}], } ], - ) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_load_path}", + "idpMetadataUrl": "", + "idpEntityId": "", + "idpSsoUrl": "", + "acsAllowedCallbacks": [], + "subjectNameIdType": "", + "subjectNameIdFormat": "", + }, + "oidcSettings": {"loginPageUrl": "", "issuer": "", "discoveryUrl": ""}, + } + with client.mock_mgmt_get(make_response(load_resp)) as mock_get: + resp = await client.invoke(client.mgmt.sso_application.load("app1")) + assert resp["name"] == "App1" + assert resp["appType"] == "saml" + assert resp["samlSettings"]["loginPageUrl"] == "http://dummy.com/login" + assert resp["samlSettings"]["useMetadataInfo"] is True + assert resp["samlSettings"]["metadataUrl"] == "http://dummy.com/md" + assert resp["samlSettings"]["attributeMapping"] == [ + {"name": "email", "type": "", "value": "attrVal1"} + ] + assert resp["samlSettings"]["groupsMapping"] == [ + { + "name": "grp1", + "type": "", + "filterType": "roles", + "value": "", + "roles": [{"id": "myRoleId", "name": "myRole"}], + } + ] + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"id": "app1"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.sso_application.load_all) + with client.mock_mgmt_get(make_response(status=400)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso_application.load_all()) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ + load_all_resp = { + "apps": [ { - "apps": [ - {"id":"app1","name":"App1","description":"","enabled":true,"logo":"","appType":"saml","samlSettings":{"loginPageUrl":"http://dummy.com/login","idpCert":"cert","useMetadataInfo":true,"metadataUrl":"http://dummy.com/md","entityId":"","acsUrl":"","certificate":"","attributeMapping":[{"name":"email","type":"","value":"attrVal1"}],"groupsMapping":[{"name":"grp1","type":"","filterType":"roles","value":"","roles":[{"id":"myRoleId","name":"myRole"}]}],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":""},"oidcSettings":{"loginPageUrl":"","issuer":"","discoveryUrl":""}}, - {"id":"app2","name":"App2","description":"","enabled":true,"logo":"","appType":"oidc","samlSettings":{"loginPageUrl":"","idpCert":"","useMetadataInfo":false,"metadataUrl":"","entityId":"","acsUrl":"","certificate":"","attributeMapping":[],"groupsMapping":[],"idpMetadataUrl":"","idpEntityId":"","idpSsoUrl":"","acsAllowedCallbacks":[],"subjectNameIdType":"","subjectNameIdFormat":""},"oidcSettings":{"loginPageUrl":"http://dummy.com/login","issuer":"http://dummy.com/issuer","discoveryUrl":"http://dummy.com/wellknown"}} - ] - } - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.sso_application.load_all() + "id": "app1", + "name": "App1", + "description": "", + "enabled": True, + "logo": "", + "appType": "saml", + "samlSettings": { + "loginPageUrl": "http://dummy.com/login", + "idpCert": "cert", + "useMetadataInfo": True, + "metadataUrl": "http://dummy.com/md", + "entityId": "", + "acsUrl": "", + "certificate": "", + "attributeMapping": [{"name": "email", "type": "", "value": "attrVal1"}], + "groupsMapping": [ + { + "name": "grp1", + "type": "", + "filterType": "roles", + "value": "", + "roles": [{"id": "myRoleId", "name": "myRole"}], + } + ], + "idpMetadataUrl": "", + "idpEntityId": "", + "idpSsoUrl": "", + "acsAllowedCallbacks": [], + "subjectNameIdType": "", + "subjectNameIdFormat": "", + }, + "oidcSettings": {"loginPageUrl": "", "issuer": "", "discoveryUrl": ""}, + }, + { + "id": "app2", + "name": "App2", + "description": "", + "enabled": True, + "logo": "", + "appType": "oidc", + "samlSettings": { + "loginPageUrl": "", + "idpCert": "", + "useMetadataInfo": False, + "metadataUrl": "", + "entityId": "", + "acsUrl": "", + "certificate": "", + "attributeMapping": [], + "groupsMapping": [], + "idpMetadataUrl": "", + "idpEntityId": "", + "idpSsoUrl": "", + "acsAllowedCallbacks": [], + "subjectNameIdType": "", + "subjectNameIdFormat": "", + }, + "oidcSettings": { + "loginPageUrl": "http://dummy.com/login", + "issuer": "http://dummy.com/issuer", + "discoveryUrl": "http://dummy.com/wellknown", + }, + }, + ] + } + with client.mock_mgmt_get(make_response(load_all_resp)) as mock_get: + resp = await client.invoke(client.mgmt.sso_application.load_all()) apps = resp["apps"] - self.assertEqual(len(apps), 2) - self.assertEqual(apps[0]["name"], "App1") - self.assertEqual(apps[0]["appType"], "saml") - self.assertEqual(apps[0]["samlSettings"]["loginPageUrl"], "http://dummy.com/login") - self.assertEqual(apps[0]["samlSettings"]["useMetadataInfo"], True) - self.assertEqual(apps[0]["samlSettings"]["metadataUrl"], "http://dummy.com/md") - self.assertEqual( - apps[0]["samlSettings"]["attributeMapping"], - [{"name": "email", "type": "", "value": "attrVal1"}], - ) - self.assertEqual( - apps[0]["samlSettings"]["groupsMapping"], - [ - { - "name": "grp1", - "type": "", - "filterType": "roles", - "value": "", - "roles": [{"id": "myRoleId", "name": "myRole"}], - } - ], - ) - - self.assertEqual(apps[1]["name"], "App2") - self.assertEqual(apps[1]["appType"], "oidc") - self.assertEqual(apps[1]["oidcSettings"]["loginPageUrl"], "http://dummy.com/login") - self.assertEqual(apps[1]["oidcSettings"]["issuer"], "http://dummy.com/issuer") - self.assertEqual(apps[1]["oidcSettings"]["discoveryUrl"], "http://dummy.com/wellknown") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_application_load_all_path}", + assert len(apps) == 2 + assert apps[0]["name"] == "App1" + assert apps[0]["appType"] == "saml" + assert apps[0]["samlSettings"]["loginPageUrl"] == "http://dummy.com/login" + assert apps[0]["samlSettings"]["useMetadataInfo"] is True + assert apps[0]["samlSettings"]["metadataUrl"] == "http://dummy.com/md" + assert apps[0]["samlSettings"]["attributeMapping"] == [ + {"name": "email", "type": "", "value": "attrVal1"} + ] + assert apps[0]["samlSettings"]["groupsMapping"] == [ + { + "name": "grp1", + "type": "", + "filterType": "roles", + "value": "", + "roles": [{"id": "myRoleId", "name": "myRole"}], + } + ] + assert apps[1]["name"] == "App2" + assert apps[1]["appType"] == "oidc" + assert apps[1]["oidcSettings"]["loginPageUrl"] == "http://dummy.com/login" + assert apps[1]["oidcSettings"]["issuer"] == "http://dummy.com/issuer" + assert apps[1]["oidcSettings"]["discoveryUrl"] == "http://dummy.com/wellknown" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_load_all_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 198f4fa33..45085c488 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -1,9 +1,8 @@ import json -from unittest import mock -from unittest.mock import patch -from descope import AttributeMapping, AuthException, DescopeClient, RoleMapping -from descope.common import DEFAULT_TIMEOUT_SECONDS +import pytest + +from descope import AttributeMapping, AuthException, RoleMapping from descope.management.common import MgmtV1 from descope.management.sso_settings import ( FGAGroupMapping, @@ -15,141 +14,91 @@ SSOSettings, ) -from .. import common -from ..testutils import SSLMatcher - +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT -class TestSSOSettings(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - def test_delete_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) +class TestSSOSettings: + async def test_delete_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.delete_settings, - "tenant-id", - ) + with client.mock_mgmt_delete(make_response(status=500)) as mock_delete: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.delete_settings("tenant-id")) # Test success flow - with patch("httpx.delete") as mock_delete: - network_resp = mock.Mock() - network_resp.is_success = True - - mock_delete.return_value = network_resp - client.mgmt.sso.delete_settings("tenant-id") + with client.mock_mgmt_delete(make_response()) as mock_delete: + await client.invoke(client.mgmt.sso.delete_settings("tenant-id")) - mock_delete.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + assert_http_called( + mock_delete, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", params={"tenantId": "tenant-id"}, headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.load_settings, - "tenant-id", - ) + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.load_settings("tenant-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": ["cert1", "cert2"], "defaultSSORoles": ["aa", "bb"], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": ""}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": false, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": ""}}""" - ) - mock_get.return_value = network_resp - resp = client.mgmt.sso.load_settings("T2AAAA") + resp_data = json.loads( + """{"tenant": {"id": "T2AAAA", "name": "myTenantName", "selfProvisioningDomains": [], "customAttributes": {}, "authType": "saml", "domains": ["lulu", "kuku"]}, "saml": {"idpEntityId": "", "idpSSOUrl": "", "idpCertificate": "", "idpAdditionalCertificates": ["cert1", "cert2"], "defaultSSORoles": ["aa", "bb"], "idpMetadataUrl": "https://dummy.com/metadata", "spEntityId": "", "spACSUrl": "", "spCertificate": "", "attributeMapping": {"name": "name", "email": "email", "username": "", "phoneNumber": "phone", "group": "", "givenName": "", "middleName": "", "familyName": "", "picture": "", "customAttributes": {}}, "groupsMapping": [], "redirectUrl": ""}, "oidc": {"name": "", "clientId": "", "clientSecret": "", "redirectUrl": "", "authUrl": "", "tokenUrl": "", "userDataUrl": "", "scope": [], "JWKsUrl": "", "userAttrMapping": {"loginId": "sub", "username": "", "name": "name", "email": "email", "phoneNumber": "phone_number", "verifiedEmail": "email_verified", "verifiedPhone": "phone_number_verified", "picture": "picture", "givenName": "given_name", "middleName": "middle_name", "familyName": "family_name"}, "manageProviderTokens": false, "callbackDomain": "", "prompt": [], "grantType": "authorization_code", "issuer": ""}}""" + ) + with client.mock_mgmt_get(make_response(resp_data)) as mock_get: + resp = await client.invoke(client.mgmt.sso.load_settings("T2AAAA")) tenant = resp.get("tenant", {}) - self.assertEqual(tenant.get("id", ""), "T2AAAA") - self.assertEqual(tenant.get("domains", []), ["lulu", "kuku"]) + assert tenant.get("id", "") == "T2AAAA" + assert tenant.get("domains", []) == ["lulu", "kuku"] saml_settings = resp.get("saml", {}) - self.assertEqual(saml_settings.get("idpMetadataUrl", ""), "https://dummy.com/metadata") - self.assertEqual( - saml_settings.get("defaultSSORoles", ""), - ["aa", "bb"], - ) - self.assertEqual( - saml_settings.get("idpAdditionalCertificates", []), - ["cert1", "cert2"], - ) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_load_settings_path}", + assert saml_settings.get("idpMetadataUrl", "") == "https://dummy.com/metadata" + assert saml_settings.get("defaultSSORoles", "") == ["aa", "bb"] + assert saml_settings.get("idpAdditionalCertificates", []) == ["cert1", "cert2"] + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_load_settings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"tenantId": "T2AAAA"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_oidc_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_oidc_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.configure_oidc_settings, - "tenant-id", - SSOOIDCSettings( - name="myName", - client_id="cid", - ), - ["domain.com"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure_oidc_settings( + "tenant-id", + SSOOIDCSettings( + name="myName", + client_id="cid", + ), + ["domain.com"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_oidc_settings( "tenant-id", SSOOIDCSettings( @@ -179,12 +128,15 @@ def test_configure_oidc_settings(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -223,41 +175,33 @@ def test_configure_oidc_settings(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_saml_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_saml_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.configure_saml_settings, - "tenant-id", - SSOSAMLSettings( - idp_url="http://dummy.com", - idp_entity_id="ent1234", - idp_cert="cert", - sp_acs_url="http://spacsurl.com", - sp_entity_id="spentityid", - default_sso_roles=["aa", "bb"], - ), - "https://redirect.com", - ["domain.com"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure_saml_settings( + "tenant-id", + SSOSAMLSettings( + idp_url="http://dummy.com", + idp_entity_id="ent1234", + idp_cert="cert", + sp_acs_url="http://spacsurl.com", + sp_entity_id="spentityid", + default_sso_roles=["aa", "bb"], + ), + "https://redirect.com", + ["domain.com"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_saml_settings( "tenant-id", SSOSAMLSettings( @@ -284,12 +228,15 @@ def test_configure_saml_settings(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -323,34 +270,26 @@ def test_configure_saml_settings(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_saml_settings_by_metadata(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_saml_settings_by_metadata(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.configure_saml_settings_by_metadata, - "tenant-id", - SSOSAMLSettingsByMetadata(idp_metadata_url="http://dummy.com/metadata"), - "https://redirect.com", - ["domain.com"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure_saml_settings_by_metadata( + "tenant-id", + SSOSAMLSettingsByMetadata(idp_metadata_url="http://dummy.com/metadata"), + "https://redirect.com", + ["domain.com"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_saml_settings_by_metadata( "tenant-id", SSOSAMLSettingsByMetadata( @@ -375,12 +314,15 @@ def test_configure_saml_settings_by_metadata(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -411,22 +353,14 @@ def test_configure_saml_settings_by_metadata(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_saml_settings_with_additional_certs(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_saml_settings_with_additional_certs(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test success flow with additional certs - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_saml_settings( "tenant-id", SSOSAMLSettings( @@ -446,12 +380,15 @@ def test_configure_saml_settings_with_additional_certs(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -485,19 +422,18 @@ def test_configure_saml_settings_with_additional_certs(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) def test_attribute_mapping_to_dict(self): - self.assertRaises(ValueError, SSOSettings._attribute_mapping_to_dict, None) + with pytest.raises(ValueError): + SSOSettings._attribute_mapping_to_dict(None) def test_fga_mappings_to_dict(self): # None input returns None - self.assertIsNone(SSOSettings._fga_mappings_to_dict(None)) + assert SSOSettings._fga_mappings_to_dict(None) is None # Empty dict returns empty dict - self.assertEqual(SSOSettings._fga_mappings_to_dict({}), {}) + assert SSOSettings._fga_mappings_to_dict({}) == {} # Group with relations is serialized into camelCase keys mappings = { @@ -517,38 +453,29 @@ def test_fga_mappings_to_dict(self): ), "viewers": FGAGroupMapping(), } - self.assertEqual( - SSOSettings._fga_mappings_to_dict(mappings), - { - "admins": { - "relations": [ - { - "resource": "tenant:t1", - "relationDefinition": "member", - "namespace": "tenant", - }, - { - "resource": "tenant:t1", - "relationDefinition": "owner", - "namespace": "tenant", - }, - ], - }, - "viewers": {"relations": []}, + assert SSOSettings._fga_mappings_to_dict(mappings) == { + "admins": { + "relations": [ + { + "resource": "tenant:t1", + "relationDefinition": "member", + "namespace": "tenant", + }, + { + "resource": "tenant:t1", + "relationDefinition": "owner", + "namespace": "tenant", + }, + ], }, - ) + "viewers": {"relations": []}, + } - def test_configure_saml_settings_with_fga_mappings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_saml_settings_with_fga_mappings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_saml_settings( "tenant-id", SSOSAMLSettings( @@ -573,12 +500,15 @@ def test_configure_saml_settings_with_fga_mappings(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -612,21 +542,13 @@ def test_configure_saml_settings_with_fga_mappings(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_saml_settings_by_metadata_with_fga_mappings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_saml_settings_by_metadata_with_fga_mappings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_saml_settings_by_metadata( "tenant-id", SSOSAMLSettingsByMetadata( @@ -647,12 +569,15 @@ def test_configure_saml_settings_by_metadata_with_fga_mappings(self): ), ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_saml_by_metadata_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -683,21 +608,13 @@ def test_configure_saml_settings_by_metadata_with_fga_mappings(self): "domains": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_oidc_settings_with_fga_mappings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_oidc_settings_with_fga_mappings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_oidc_settings( "tenant-id", SSOOIDCSettings( @@ -717,12 +634,15 @@ def test_configure_oidc_settings_with_fga_mappings(self): ), ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_configure_oidc_settings}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -759,76 +679,55 @@ def test_configure_oidc_settings_with_fga_mappings(self): "domains": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Testing DEPRECATED functions - def test_get_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_get_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.get_settings, - "tenant-id", - ) + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.get_settings("tenant-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"domains": ["lulu", "kuku"], "tenantId": "tenant-id"}""") - mock_get.return_value = network_resp - resp = client.mgmt.sso.get_settings("tenant-id") - self.assertEqual(resp["tenantId"], "tenant-id") - self.assertEqual(resp["domains"], ["lulu", "kuku"]) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + with client.mock_mgmt_get(make_response({"domains": ["lulu", "kuku"], "tenantId": "tenant-id"})) as mock_get: + resp = await client.invoke(client.mgmt.sso.get_settings("tenant-id")) + assert resp["tenantId"] == "tenant-id" + assert resp["domains"] == ["lulu", "kuku"] + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"tenantId": "tenant-id"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.configure, - "tenant-id", - "https://idp.com", - "entity-id", - "cert", - "https://redirect.com", - ["domain.com"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure( + "tenant-id", + "https://idp.com", + "entity-id", + "cert", + "https://redirect.com", + ["domain.com"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure( "tenant-id", "https://idp.com", @@ -838,12 +737,15 @@ def test_configure(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -855,14 +757,11 @@ def test_configure(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Domain is optional - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure( "tenant-id", "https://idp.com", @@ -871,12 +770,15 @@ def test_configure(self): "https://redirect.com", ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -888,14 +790,11 @@ def test_configure(self): "domains": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Redirect is optional - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure( "tenant-id", "https://idp.com", @@ -905,12 +804,15 @@ def test_configure(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_settings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -922,34 +824,26 @@ def test_configure(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_configure_via_metadata(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_configure_via_metadata(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.configure_via_metadata, - "tenant-id", - "https://idp-meta.com", - "https://redirect.com", - ["domain.com"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure_via_metadata( + "tenant-id", + "https://idp-meta.com", + "https://redirect.com", + ["domain.com"], + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_via_metadata( "tenant-id", "https://idp-meta.com", @@ -957,12 +851,15 @@ def test_configure_via_metadata(self): ["domain.com"], ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -972,25 +869,25 @@ def test_configure_via_metadata(self): "domains": ["domain.com"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test partial arguments - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.configure_via_metadata( "tenant-id", "https://idp-meta.com", ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_metadata_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1000,45 +897,40 @@ def test_configure_via_metadata(self): "domains": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_mapping(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_mapping(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.mapping, - "tenant-id", - [RoleMapping(["a", "b"], "role")], - AttributeMapping(name="UName"), - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.mapping( + "tenant-id", + [RoleMapping(["a", "b"], "role")], + AttributeMapping(name="UName"), + ) + ) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.sso.mapping( "tenant-id", [RoleMapping(["a", "b"], "role")], AttributeMapping(name="UName"), ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_mapping_path}", + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_mapping_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1057,40 +949,27 @@ def test_mapping(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_recalculate_sso_mappings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_recalculate_sso_mappings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.sso.recalculate_sso_mappings, - "tenant-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id")) # Test success flow with sso_id - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = {"affectedUserIds": ["user1", "user2", "user3"]} - mock_post.return_value = network_resp - client.mgmt.sso.recalculate_sso_mappings("tenant-id", "sso-456") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", + with client.mock_mgmt_post(make_response({"affectedUserIds": ["user1", "user2", "user3"]})) as mock_post: + await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id", "sso-456")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1098,29 +977,23 @@ def test_recalculate_sso_mappings(self): "ssoId": "sso-456", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow without sso_id - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = {"affectedUserIds": ["user1"]} - mock_post.return_value = network_resp - client.mgmt.sso.recalculate_sso_mappings("tenant-id") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", + with client.mock_mgmt_post(make_response({"affectedUserIds": ["user1"]})) as mock_post: + await client.invoke(client.mgmt.sso.recalculate_sso_mappings("tenant-id")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_recalculate_mappings_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "tenantId": "tenant-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 429683dfc..9acf2dd5f 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -1,66 +1,41 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope import AuthException from descope.management.common import ( MgmtV1, SSOSetupSuiteSettings, SSOSetupSuiteSettingsDisabledFeatures, ) -from .. import common -from ..testutils import SSLMatcher - - -class TestTenant(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - - def test_create(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.create, - "valid-name", - ) +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + +MGMT_HEADERS = { + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, +} + + +class TestTenant: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.create("valid-name")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"id": "t1"}""") - mock_post.return_value = network_resp - resp = client.mgmt.tenant.create("name", "t1", ["domain.com"]) - self.assertEqual(resp["id"], "t1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response({"id": "t1"})) as mock_post: + resp = await client.invoke(client.mgmt.tenant.create("name", "t1", ["domain.com"])) + assert resp["id"] == "t1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", + headers=MGMT_HEADERS, params=None, json={ "name": "name", @@ -70,34 +45,28 @@ def test_create(self): "disabled": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with custom attributes, enforce_sso, disabled - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"id": "t1"}""") - mock_post.return_value = network_resp - resp = client.mgmt.tenant.create( - "name", - "t1", - ["domain.com"], - {"k1": "v1"}, - enforce_sso=True, - enforce_sso_exclusions=["user1", "user2"], - federated_app_ids=["app1", "app2"], - disabled=True, + with client.mock_mgmt_post(make_response({"id": "t1"})) as mock_post: + resp = await client.invoke( + client.mgmt.tenant.create( + "name", + "t1", + ["domain.com"], + {"k1": "v1"}, + enforce_sso=True, + enforce_sso_exclusions=["user1", "user2"], + federated_app_ids=["app1", "app2"], + disabled=True, + ) ) - self.assertEqual(resp["id"], "t1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert resp["id"] == "t1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_create_path}", + headers=MGMT_HEADERS, params=None, json={ "name": "name", @@ -110,41 +79,27 @@ def test_create(self): "disabled": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.update, - "valid-id", - "valid-name", - ) + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.update("valid-id", "valid-name")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.tenant.update("t1", "new-name", ["domain.com"], enforce_sso=True, disabled=True) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", + headers=MGMT_HEADERS, params=None, json={ "name": "new-name", @@ -154,14 +109,11 @@ def test_update(self): "disabled": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with custom attributes, enforce_sso, disabled - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.tenant.update( "t1", "new-name", @@ -173,13 +125,12 @@ def test_update(self): disabled=True, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_path}", + headers=MGMT_HEADERS, params=None, json={ "name": "new-name", @@ -192,180 +143,120 @@ def test_update(self): "disabled": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.delete, - "valid-id", - ) + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.delete("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.tenant.delete("t1", True)) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_delete_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.tenant.delete("t1", True)) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_delete_path}", + headers=MGMT_HEADERS, params=None, json={"id": "t1", "cascade": True}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.load, - "valid-id", - ) + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.load("valid-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520} - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.tenant.load("t1") - self.assertEqual(resp["name"], "tenant1") - self.assertEqual(resp["createdTime"], 172606520) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_load_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_get( + make_response({"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520}) + ) as mock_get: + resp = await client.invoke(client.mgmt.tenant.load("t1")) + assert resp["name"] == "tenant1" + assert resp["createdTime"] == 172606520 + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_load_path}", + headers=MGMT_HEADERS, params={"id": "t1"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_all(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.tenant.load_all) + # Test failed flow + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.load_all()) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - { - "tenants": [ - {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520}, - {"id": "t2", "name": "tenant2", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520} - ] - } - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.tenant.load_all() + with client.mock_mgmt_get( + make_response({ + "tenants": [ + {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520}, + {"id": "t2", "name": "tenant2", "selfProvisioningDomains": ["domain1.com"], "createdTime": 172606520}, + ] + }) + ) as mock_get: + resp = await client.invoke(client.mgmt.tenant.load_all()) tenants = resp["tenants"] - self.assertEqual(len(tenants), 2) - self.assertEqual(tenants[0]["name"], "tenant1") - self.assertEqual(tenants[1]["name"], "tenant2") - self.assertEqual(tenants[0]["createdTime"], 172606520) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_load_all_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert len(tenants) == 2 + assert tenants[0]["name"] == "tenant1" + assert tenants[1]["name"] == "tenant2" + assert tenants[0]["createdTime"] == 172606520 + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_load_all_path}", + headers=MGMT_HEADERS, params=None, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search_all(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_search_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, client.mgmt.tenant.search_all) + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.search_all()) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - { - "tenants": [ - {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"]}, - {"id": "t2", "name": "tenant2", "selfProvisioningDomains": ["domain1.com"]} - ] - } - """ - ) - mock_post.return_value = network_resp - resp = client.mgmt.tenant.search_all( - ids=["id1"], - names=["name1"], - custom_attributes={"k1": "v1"}, - self_provisioning_domains=["spd1"], + with client.mock_mgmt_post( + make_response({ + "tenants": [ + {"id": "t1", "name": "tenant1", "selfProvisioningDomains": ["domain1.com"]}, + {"id": "t2", "name": "tenant2", "selfProvisioningDomains": ["domain1.com"]}, + ] + }) + ) as mock_post: + resp = await client.invoke( + client.mgmt.tenant.search_all( + ids=["id1"], + names=["name1"], + custom_attributes={"k1": "v1"}, + self_provisioning_domains=["spd1"], + ) ) tenants = resp["tenants"] - self.assertEqual(len(tenants), 2) - self.assertEqual(tenants[0]["name"], "tenant1") - self.assertEqual(tenants[1]["name"], "tenant2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_search_all_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert len(tenants) == 2 + assert tenants[0]["name"] == "tenant1" + assert tenants[1]["name"] == "tenant2" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_search_all_path}", + headers=MGMT_HEADERS, json={ "tenantIds": ["id1"], "tenantNames": ["name1"], @@ -374,32 +265,19 @@ def test_search_all(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.update_settings, - "valid-id", - {}, - ) + async def test_update_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.update_settings("valid-id", {})) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone( + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke( client.mgmt.tenant.update_settings( "t1", self_provisioning_domains=["domain1.com"], @@ -408,13 +286,12 @@ def test_update_settings(self): session_settings_enabled=True, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", + headers=MGMT_HEADERS, json={ "tenantId": "t1", "selfProvisioningDomains": ["domain1.com"], @@ -424,13 +301,10 @@ def test_update_settings(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with SSO Setup Suite settings - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True + with client.mock_mgmt_post(make_response()) as mock_post: sso_disabled_features = SSOSetupSuiteSettingsDisabledFeatures( saml=True, oidc=False, scim=True, sso_domains=False, group_mapping=True ) @@ -439,7 +313,7 @@ def test_update_settings(self): style_id="style123", disabled_features=sso_disabled_features, ) - self.assertIsNone( + result = await client.invoke( client.mgmt.tenant.update_settings( "t1", self_provisioning_domains=["domain1.com"], @@ -449,13 +323,12 @@ def test_update_settings(self): sso_setup_suite_settings=sso_settings, ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", + headers=MGMT_HEADERS, json={ "tenantId": "t1", "selfProvisioningDomains": ["domain1.com"], @@ -476,224 +349,146 @@ def test_update_settings(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_settings(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.load_settings, - "valid-id", - ) + async def test_load_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.load_settings("valid-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - {"domains": ["domain1.com", "domain2.com"], "authType": "oidc", "sessionSettingsEnabled": true} - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.tenant.load_settings("t1") - self.assertEqual(resp["domains"], ["domain1.com", "domain2.com"]) - self.assertEqual(resp["authType"], "oidc") - self.assertEqual(resp["sessionSettingsEnabled"], True) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_get( + make_response({"domains": ["domain1.com", "domain2.com"], "authType": "oidc", "sessionSettingsEnabled": True}) + ) as mock_get: + resp = await client.invoke(client.mgmt.tenant.load_settings("t1")) + assert resp["domains"] == ["domain1.com", "domain2.com"] + assert resp["authType"] == "oidc" + assert resp["sessionSettingsEnabled"] is True + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", + headers=MGMT_HEADERS, params={"id": "t1"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_default_roles(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.update_default_roles, - "valid-id", - ["role1"], - ) + async def test_update_default_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.update_default_roles("valid-id", ["role1"])) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.tenant.update_default_roles("t1", ["role1", "role2"])) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_update_default_roles_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post(make_response()) as mock_post: + result = await client.invoke(client.mgmt.tenant.update_default_roles("t1", ["role1", "role2"])) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_update_default_roles_path}", + headers=MGMT_HEADERS, params=None, json={"id": "t1", "defaultRoles": ["role1", "role2"]}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - # Test success flow with SSO Setup Suite settings - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - { - "domains": ["domain1.com", "domain2.com"], - "authType": "oidc", - "sessionSettingsEnabled": true, - "ssoSetupSuiteSettings": { - "enabled": true, - "styleId": "style123", - "disabledFeatures": { - "saml": true, - "oidc": false, - "scim": true, - "ssoDomains": false, - "groupMapping": true - } - } - } - """ - ) - mock_get.return_value = network_resp - resp = client.mgmt.tenant.load_settings("t1") - self.assertEqual(resp["domains"], ["domain1.com", "domain2.com"]) - self.assertEqual(resp["authType"], "oidc") - self.assertEqual(resp["sessionSettingsEnabled"], True) - sso_settings = resp["ssoSetupSuiteSettings"] - self.assertEqual(sso_settings["enabled"], True) - self.assertEqual(sso_settings["styleId"], "style123") - disabled_features = sso_settings["disabledFeatures"] - self.assertEqual(disabled_features["saml"], True) - self.assertEqual(disabled_features["oidc"], False) - self.assertEqual(disabled_features["scim"], True) - self.assertEqual(disabled_features["ssoDomains"], False) - self.assertEqual(disabled_features["groupMapping"], True) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + # Test load_settings with SSO Setup Suite settings response + with client.mock_mgmt_get( + make_response({ + "domains": ["domain1.com", "domain2.com"], + "authType": "oidc", + "sessionSettingsEnabled": True, + "ssoSetupSuiteSettings": { + "enabled": True, + "styleId": "style123", + "disabledFeatures": { + "saml": True, + "oidc": False, + "scim": True, + "ssoDomains": False, + "groupMapping": True, + }, }, + }) + ) as mock_get: + resp = await client.invoke(client.mgmt.tenant.load_settings("t1")) + assert resp["domains"] == ["domain1.com", "domain2.com"] + assert resp["authType"] == "oidc" + assert resp["sessionSettingsEnabled"] is True + sso = resp["ssoSetupSuiteSettings"] + assert sso["enabled"] is True + assert sso["styleId"] == "style123" + disabled = sso["disabledFeatures"] + assert disabled["saml"] is True + assert disabled["oidc"] is False + assert disabled["scim"] is True + assert disabled["ssoDomains"] is False + assert disabled["groupMapping"] is True + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_settings_path}", + headers=MGMT_HEADERS, params={"id": "t1"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_sso_configuration_link_success(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_generate_sso_configuration_link_success(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"adminSSOConfigurationLink": "https://example.com/sso-config-link"}""" - ) - mock_post.return_value = network_resp - link = client.mgmt.tenant.generate_sso_configuration_link("t1", 21600) - self.assertEqual(link, "https://example.com/sso-config-link") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + with client.mock_mgmt_post( + make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) + ) as mock_post: + link = await client.invoke(client.mgmt.tenant.generate_sso_configuration_link("t1", 21600)) + assert link == "https://example.com/sso-config-link" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", + headers=MGMT_HEADERS, params=None, json={ "tenantId": "t1", "expireTime": 21600, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_sso_configuration_link_failed(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) + async def test_generate_sso_configuration_link_failed(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - # Test failed flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - client.mgmt.tenant.generate_sso_configuration_link, - "t1", - 21600, - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.tenant.generate_sso_configuration_link("t1", 21600) + ) - def test_generate_sso_configuration_link_with_all_params(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test success flow with all parameters - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"adminSSOConfigurationLink": "https://example.com/sso-config-link"}""" - ) - mock_post.return_value = network_resp - link = client.mgmt.tenant.generate_sso_configuration_link( - tenant_id="t1", - expire_time=21600, - email="admin@example.com", - sso_id="sso123", + async def test_generate_sso_configuration_link_with_all_params(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post( + make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) + ) as mock_post: + link = await client.invoke( + client.mgmt.tenant.generate_sso_configuration_link( + tenant_id="t1", + expire_time=21600, + email="admin@example.com", + sso_id="sso123", + ) ) - self.assertEqual(link, "https://example.com/sso-config-link") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + assert link == "https://example.com/sso-config-link" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", + headers=MGMT_HEADERS, params=None, json={ "tenantId": "t1", @@ -702,38 +497,22 @@ def test_generate_sso_configuration_link_with_all_params(self): "ssoId": "sso123", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_sso_configuration_link_minimal_params(self): - client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - # Test success flow with only required parameter - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"adminSSOConfigurationLink": "https://example.com/sso-config-link"}""" - ) - mock_post.return_value = network_resp - link = client.mgmt.tenant.generate_sso_configuration_link("t1") - self.assertEqual(link, "https://example.com/sso-config-link") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, - }, + async def test_generate_sso_configuration_link_minimal_params(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post( + make_response({"adminSSOConfigurationLink": "https://example.com/sso-config-link"}) + ) as mock_post: + link = await client.invoke(client.mgmt.tenant.generate_sso_configuration_link("t1")) + assert link == "https://example.com/sso-config-link" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_generate_sso_configuration_link_path}", + headers=MGMT_HEADERS, params=None, json={"tenantId": "t1"}, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index 7713cac4b..a28f884a0 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -1,9 +1,7 @@ -import json -from unittest import mock -from unittest.mock import patch +import pytest -from descope import AssociatedTenant, AuthException, DescopeClient -from descope.common import DEFAULT_TIMEOUT_SECONDS, DeliveryMethod, LoginOptions +from descope import AssociatedTenant, AuthException +from descope.common import DeliveryMethod, LoginOptions from descope.management.common import MgmtV1, Sort from descope.management.user import UserObj from descope.management.user_pwd import ( @@ -14,68 +12,46 @@ UserPasswordPbkdf2, ) -from .. import common -from ..testutils import SSLMatcher - - -class TestUser(common.DescopeTest): - def setUp(self) -> None: - super().setUp() - self.dummy_project_id = "dummy" - self.dummy_management_key = "key" - self.public_key_dict = { - "alg": "ES384", - "crv": "P-384", - "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", - "kty": "EC", - "use": "sig", - "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", - "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", - } - self.client = DescopeClient( - self.dummy_project_id, - self.public_key_dict, - False, - self.dummy_management_key, - ) - - def test_create(self): +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.testutils import PUBLIC_KEY_DICT + + +class TestUser: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.create, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.create("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.create( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - picture="https://test.com", - custom_attributes={"ak": "av"}, - additional_login_ids=["id-1", "id-2"], - sso_app_ids=["app1", "app2"], + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.create( + login_id="name@mail.com", + email="name@mail.com", + display_name="Name", + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + picture="https://test.com", + custom_attributes={"ak": "av"}, + additional_login_ids=["id-1", "id-2"], + sso_app_ids=["app1", "app2"], + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -96,38 +72,37 @@ def test_create(self): "ssoAppIDs": ["app1", "app2"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_with_verified_parameters(self): + async def test_create_with_verified_parameters(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test success flow with verified email and phone - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.create( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - picture="https://test.com", - custom_attributes={"ak": "av"}, - verified_email=True, - verified_phone=False, + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.create( + login_id="name@mail.com", + email="name@mail.com", + display_name="Name", + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + picture="https://test.com", + custom_attributes={"ak": "av"}, + verified_email=True, + verified_phone=False, + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -150,44 +125,39 @@ def test_create_with_verified_parameters(self): "ssoAppIDs": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_create_test_user(self): + async def test_create_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.create, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.create("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.create_test_user( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes={"ak": "av"}, + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.create_test_user( + login_id="name@mail.com", + email="name@mail.com", + display_name="Name", + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + custom_attributes={"ak": "av"}, + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_user_create_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_user_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -208,49 +178,44 @@ def test_create_test_user(self): "ssoAppIDs": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_invite(self): + async def test_invite(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.invite, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.invite("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.invite( - login_id="name@mail.com", - email="name@mail.com", - display_name="Name", - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes={"ak": "av"}, - invite_url="invite.me", - send_sms=True, - sso_app_ids=["app1", "app2"], - template_id="tid", - locale="en", + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.invite( + login_id="name@mail.com", + email="name@mail.com", + display_name="Name", + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + custom_attributes={"ak": "av"}, + invite_url="invite.me", + send_sms=True, + sso_app_ids=["app1", "app2"], + template_id="tid", + locale="en", + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -275,26 +240,18 @@ def test_invite(self): "locale": "en", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_invite_batch(self): + async def test_invite_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.invite_batch, - [], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.invite_batch([])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}]}""") - mock_post.return_value = network_resp + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}]})) as mock_post: user = UserObj( login_id="name@mail.com", email="name@mail.com", @@ -318,14 +275,16 @@ def test_invite_batch(self): seed="aaa", status="invited", ) - resp = self.client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", + resp = await client.invoke( + client.mgmt.user.invite_batch( + users=[user], + invite_url="invite.me", + send_sms=True, + locale="en", + ) ) users = resp["users"] - self.assertEqual(users[0]["id"], "u1") + assert users[0]["id"] == "u1" expected_users = { "users": [ @@ -366,119 +325,113 @@ def test_invite_batch(self): "sendSMS": True, "locale": "en", } - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json=expected_users, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) bcrypt = UserPasswordBcrypt(hash="h") - self.assertEqual(bcrypt.to_dict(), {"bcrypt": {"hash": "h"}}) + assert bcrypt.to_dict() == {"bcrypt": {"hash": "h"}} pbkdf2 = UserPasswordPbkdf2(hash="h", salt="s", iterations=14, variant="sha256") - self.assertEqual( - pbkdf2.to_dict(), - { - "pbkdf2": { - "hash": "h", - "salt": "s", - "iterations": 14, - "type": "sha256", - } - }, - ) + assert pbkdf2.to_dict() == { + "pbkdf2": { + "hash": "h", + "salt": "s", + "iterations": 14, + "type": "sha256", + } + } django = UserPasswordDjango(hash="h") - self.assertEqual(django.to_dict(), {"django": {"hash": "h"}}) + assert django.to_dict() == {"django": {"hash": "h"}} user.password = UserPassword(cleartext="clear") - resp = self.client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", + resp = await client.invoke( + client.mgmt.user.invite_batch( + users=[user], + invite_url="invite.me", + send_sms=True, + locale="en", + ) ) del expected_users["users"][0]["hashedPassword"] expected_users["users"][0]["password"] = "clear" - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json=expected_users, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) user.password = None - resp = self.client.mgmt.user.invite_batch( - users=[user], - invite_url="invite.me", - send_sms=True, - locale="en", + resp = await client.invoke( + client.mgmt.user.invite_batch( + users=[user], + invite_url="invite.me", + send_sms=True, + locale="en", + ) ) del expected_users["users"][0]["password"] - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json=expected_users, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update(self): + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update, - "valid-id", - "email@something.com", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update("valid-id", "email@something.com")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update( - "id", - display_name="new-name", - role_names=["domain.com"], - picture="https://test.com", - custom_attributes={"ak": "av"}, - sso_app_ids=["app1", "app2"], + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.update( + "id", + display_name="new-name", + role_names=["domain.com"], + picture="https://test.com", + custom_attributes={"ak": "av"}, + sso_app_ids=["app1", "app2"], + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -495,24 +448,22 @@ def test_update(self): "ssoAppIDs": ["app1", "app2"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) + # Test success flow with verified flags - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update("id", verified_email=True, verified_phone=False) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.update("id", verified_email=True, verified_phone=False) + ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -531,47 +482,41 @@ def test_update(self): "ssoAppIDs": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_patch(self): + async def test_patch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.patch") as mock_patch: - mock_patch.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.patch, - "valid-id", - "email@something.com", - ) + with client.mock_mgmt_patch(make_response(status=500)) as mock_patch: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.patch("valid-id", "email@something.com")) # Test success flow with some params set - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_patch.return_value = network_resp - resp = self.client.mgmt.user.patch( - "id", - display_name="new-name", - email=None, - phone=None, - given_name=None, - role_names=["domain.com"], - user_tenants=None, - picture="https://test.com", - custom_attributes={"ak": "av"}, - sso_app_ids=["app1", "app2"], + with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: + resp = await client.invoke( + client.mgmt.user.patch( + "id", + display_name="new-name", + email=None, + phone=None, + given_name=None, + role_names=["domain.com"], + user_tenants=None, + picture="https://test.com", + custom_attributes={"ak": "av"}, + sso_app_ids=["app1", "app2"], + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + assert user["id"] == "u1" + assert_http_called( + mock_patch, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -583,39 +528,37 @@ def test_patch(self): "ssoAppIds": ["app1", "app2"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) + # Test success flow with other params - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_patch.return_value = network_resp - resp = self.client.mgmt.user.patch( - "id", - email="a@test.com", - phone="+123456789", - given_name="given", - middle_name="middle", - family_name="family", - role_names=None, - user_tenants=[ - AssociatedTenant("tenant1"), - AssociatedTenant("tenant2", ["role1", "role2"]), - ], - custom_attributes=None, - verified_email=True, - verified_phone=False, + with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: + resp = await client.invoke( + client.mgmt.user.patch( + "id", + email="a@test.com", + phone="+123456789", + given_name="given", + middle_name="middle", + family_name="family", + role_names=None, + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + custom_attributes=None, + verified_email=True, + verified_phone=False, + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + assert user["id"] == "u1" + assert_http_called( + mock_patch, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -633,38 +576,34 @@ def test_patch(self): ], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_patch_with_status(self): + async def test_patch_with_status(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test invalid status value - with self.assertRaises(AuthException) as context: - self.client.mgmt.user.patch("valid-id", status="invalid_status") + with pytest.raises(AuthException) as exc_info: + await client.invoke(client.mgmt.user.patch("valid-id", status="invalid_status")) - self.assertEqual(context.exception.status_code, 400) - self.assertIn("Invalid status value: invalid_status", str(context.exception)) + assert exc_info.value.status_code == 400 + assert "Invalid status value: invalid_status" in str(exc_info.value) # Test valid status values valid_statuses = ["enabled", "disabled", "invited"] for status in valid_statuses: - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_patch.return_value = network_resp - - resp = self.client.mgmt.user.patch("id", status=status) + with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: + resp = await client.invoke(client.mgmt.user.patch("id", status=status)) user = resp["user"] - self.assertEqual(user["id"], "u1") + assert user["id"] == "u1" - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + assert_http_called( + mock_patch, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -672,42 +611,34 @@ def test_patch_with_status(self): "status": status, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test that status is not included when None - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_patch.return_value = network_resp - - resp = self.client.mgmt.user.patch("id", display_name="test", status=None) + with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: + resp = await client.invoke(client.mgmt.user.patch("id", display_name="test", status=None)) user = resp["user"] - self.assertEqual(user["id"], "u1") + assert user["id"] == "u1" # Verify that status is not in the JSON payload call_args = mock_patch.call_args json_payload = call_args[1]["json"] - self.assertNotIn("status", json_payload) - self.assertEqual(json_payload["displayName"], "test") + assert "status" not in json_payload + assert json_payload["displayName"] == "test" + + async def test_patch_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - def test_patch_batch(self): # Test invalid status value in batch users_with_invalid_status = [ UserObj(login_id="user1", status="invalid_status"), UserObj(login_id="user2", status="enabled"), ] - with self.assertRaises(AuthException) as context: - self.client.mgmt.user.patch_batch(users_with_invalid_status) + with pytest.raises(AuthException) as exc_info: + await client.invoke(client.mgmt.user.patch_batch(users_with_invalid_status)) - self.assertEqual(context.exception.status_code, 400) - self.assertIn( - "Invalid status value: invalid_status for user user1", - str(context.exception), - ) + assert exc_info.value.status_code == 400 + assert "Invalid status value: invalid_status for user user1" in str(exc_info.value) # Test successful batch patch users = [ @@ -716,25 +647,21 @@ def test_patch_batch(self): UserObj(login_id="user3", phone="+123456789", status="invited"), ] - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"patchedUsers": [{"id": "u1"}, {"id": "u2"}, {"id": "u3"}], "failedUsers": []}""" - ) - mock_patch.return_value = network_resp - - resp = self.client.mgmt.user.patch_batch(users) + with client.mock_mgmt_patch( + make_response({"patchedUsers": [{"id": "u1"}, {"id": "u2"}, {"id": "u3"}], "failedUsers": []}) + ) as mock_patch: + resp = await client.invoke(client.mgmt.user.patch_batch(users)) - self.assertEqual(len(resp["patchedUsers"]), 3) - self.assertEqual(len(resp["failedUsers"]), 0) + assert len(resp["patchedUsers"]) == 3 + assert len(resp["failedUsers"]) == 0 - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_batch_path}", + assert_http_called( + mock_patch, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_batch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -757,291 +684,247 @@ def test_patch_batch(self): ] }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test batch with mixed success/failure response - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"patchedUsers": [{"id": "u1"}], "failedUsers": [{"failure": "User not found", "user": {"loginId": "user2"}}]}""" + with client.mock_mgmt_patch( + make_response({"patchedUsers": [{"id": "u1"}], "failedUsers": [{"failure": "User not found", "user": {"loginId": "user2"}}]}) + ) as mock_patch: + resp = await client.invoke( + client.mgmt.user.patch_batch([UserObj(login_id="user1"), UserObj(login_id="user2")]) ) - mock_patch.return_value = network_resp - resp = self.client.mgmt.user.patch_batch([UserObj(login_id="user1"), UserObj(login_id="user2")]) - - self.assertEqual(len(resp["patchedUsers"]), 1) - self.assertEqual(len(resp["failedUsers"]), 1) - self.assertEqual(resp["failedUsers"][0]["failure"], "User not found") + assert len(resp["patchedUsers"]) == 1 + assert len(resp["failedUsers"]) == 1 + assert resp["failedUsers"][0]["failure"] == "User not found" # Test failed batch operation - with patch("httpx.patch") as mock_patch: - mock_patch.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.patch_batch, - [UserObj(login_id="user1")], - ) + with client.mock_mgmt_patch(make_response(status=500)) as mock_patch: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.patch_batch([UserObj(login_id="user1")]) + ) # Test with test users flag - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"patchedUsers": [{"id": "u1"}], "failedUsers": []}""") - mock_patch.return_value = network_resp - - self.client.mgmt.user.patch_batch([UserObj(login_id="test_user1")], test=True) + with client.mock_mgmt_patch( + make_response({"patchedUsers": [{"id": "u1"}], "failedUsers": []}) + ) as mock_patch: + await client.invoke( + client.mgmt.user.patch_batch([UserObj(login_id="test_user1")], test=True) + ) call_args = mock_patch.call_args json_payload = call_args[1]["json"] - self.assertTrue(json_payload["users"][0]["test"]) + assert json_payload["users"][0]["test"] + + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") - def test_delete(self): # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.delete, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(self.client.mgmt.user.delete("u1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.delete("u1")) is None + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "loginId": "u1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_by_user_id(self): + async def test_delete_by_user_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.delete_by_user_id, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete_by_user_id("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(self.client.mgmt.user.delete_by_user_id("u1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.delete_by_user_id("u1")) is None + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "userId": "u1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_logout(self): + async def test_logout(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.logout_user, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.logout_user("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(self.client.mgmt.user.logout_user("u1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.logout_user("u1")) is None + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "loginId": "u1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_logout_by_user_id(self): + async def test_logout_by_user_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.logout_user_by_user_id, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.logout_user_by_user_id("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertIsNone(self.client.mgmt.user.logout_user_by_user_id("u1")) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.logout_user_by_user_id("u1")) is None + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_logout_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "userId": "u1", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_all_test_users(self): + async def test_delete_all_test_users(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.delete_all_test_users, - ) + with client.mock_mgmt_delete(make_response(status=500)) as mock_delete: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete_all_test_users()) # Test success flow - with patch("httpx.delete") as mock_delete: - mock_delete.return_value.is_success = True - self.assertIsNone(self.client.mgmt.user.delete_all_test_users()) - mock_delete.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_delete_all_test_users_path}", + with client.mock_mgmt_delete(make_response({})) as mock_delete: + assert await client.invoke(client.mgmt.user.delete_all_test_users()) is None + assert_http_called( + mock_delete, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_all_test_users_path}", params=None, headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load(self): + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.load, - "valid-id", - ) + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.load("valid-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_get.return_value = network_resp - resp = self.client.mgmt.user.load("valid-id") + with client.mock_mgmt_get(make_response({"user": {"id": "u1"}})) as mock_get: + resp = await client.invoke(client.mgmt.user.load("valid-id")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_load_path}", + assert user["id"] == "u1" + assert_http_called( + mock_get, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"loginId": "valid-id"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_by_user_id(self): + async def test_load_by_user_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.get") as mock_get: - mock_get.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.load_by_user_id, - "user-id", - ) + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.load_by_user_id("user-id")) # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_get.return_value = network_resp - resp = self.client.mgmt.user.load_by_user_id("user-id") + with client.mock_mgmt_get(make_response({"user": {"id": "u1"}})) as mock_get: + resp = await client.invoke(client.mgmt.user.load_by_user_id("user-id")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_load_path}", + assert user["id"] == "u1" + assert_http_called( + mock_get, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={"userId": "user-id"}, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_load_users(self): + async def test_load_users(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.load_users, - [""], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.load_users([""])) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises(AuthException, self.client.mgmt.user.load_users, None, False) + with client.mock_mgmt_post(make_response({})) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.load_users(None, False)) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.load_users( - ["uid"], - include_invalid_users=True, + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.load_users( + ["uid"], + include_invalid_users=True, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_load_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_load_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1049,50 +932,45 @@ def test_load_users(self): "includeInvalidUsers": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search_all(self): + async def test_search_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.search_all, - ["t1, t2"], - ["r1", "r2"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.search_all(["t1, t2"], ["r1", "r2"])) - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises(AuthException, self.client.mgmt.user.search_all, [], [], -1, 0) + with client.mock_mgmt_post(make_response({})) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.search_all([], [], -1, 0)) - self.assertRaises(AuthException, self.client.mgmt.user.search_all, [], [], 0, -1) + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.search_all([], [], 0, -1)) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - sso_app_ids=["app1"], - login_ids=["l1"], + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all( + ["t1, t2"], + ["r1", "r2"], + with_test_user=True, + sso_app_ids=["app1"], + login_ids=["l1"], + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1106,35 +984,32 @@ def test_search_all(self): "loginIds": ["l1"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with text and sort - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: sort = [Sort(field="kuku", desc=True), Sort(field="bubu")] - resp = self.client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - sso_app_ids=["app1"], - text="blue", - sort=sort, + resp = await client.invoke( + client.mgmt.user.search_all( + ["t1, t2"], + ["r1", "r2"], + with_test_user=True, + sso_app_ids=["app1"], + text="blue", + sort=sort, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1152,35 +1027,32 @@ def test_search_all(self): ], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with custom attributes - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all( - ["t1, t2"], - ["r1", "r2"], - with_test_user=True, - custom_attributes={"ak": "av"}, - statuses=["invited"], - phones=["+111111"], - emails=["a@b.com"], + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all( + ["t1, t2"], + ["r1", "r2"], + with_test_user=True, + custom_attributes={"ak": "av"}, + statuses=["invited"], + phones=["+111111"], + emails=["a@b.com"], + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1196,35 +1068,31 @@ def test_search_all(self): "phones": ["+111111"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with time parameters - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all( - from_created_time=100, - to_created_time=200, - from_modified_time=300, - to_modified_time=400, - limit=10, - page=0, + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all( + from_created_time=100, + to_created_time=200, + from_modified_time=300, + to_modified_time=400, + limit=10, + page=0, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - # Verify the request body includes our time parameters - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1240,30 +1108,27 @@ def test_search_all(self): "toModifiedTime": 400, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with tenant_role_ids and tenant_role_names - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all( - tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all( + tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, + tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1277,63 +1142,48 @@ def test_search_all(self): "tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_search_all_test_users(self): + async def test_search_all_test_users(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.search_all_test_users, - ["t1, t2"], - ["r1", "r2"], - ) - - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = True - self.assertRaises( - AuthException, - self.client.mgmt.user.search_all_test_users, - [], - [], - -1, - 0, - ) - - self.assertRaises( - AuthException, - self.client.mgmt.user.search_all_test_users, - [], - [], - 0, - -1, - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.search_all_test_users(["t1, t2"], ["r1", "r2"])) + + with client.mock_mgmt_post(make_response({})) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.search_all_test_users([], [], -1, 0) + ) + + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.search_all_test_users([], [], 0, -1) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - sso_app_ids=["app1"], - login_ids=["l1"], + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all_test_users( + ["t1, t2"], + ["r1", "r2"], + sso_app_ids=["app1"], + login_ids=["l1"], + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1347,34 +1197,31 @@ def test_search_all_test_users(self): "loginIds": ["l1"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with text and sort - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: sort = [Sort(field="kuku", desc=True), Sort(field="bubu")] - resp = self.client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - sso_app_ids=["app1"], - text="blue", - sort=sort, + resp = await client.invoke( + client.mgmt.user.search_all_test_users( + ["t1, t2"], + ["r1", "r2"], + sso_app_ids=["app1"], + text="blue", + sort=sort, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1392,34 +1239,31 @@ def test_search_all_test_users(self): ], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with custom attributes - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all_test_users( - ["t1, t2"], - ["r1", "r2"], - custom_attributes={"ak": "av"}, - statuses=["invited"], - phones=["+111111"], - emails=["a@b.com"], + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all_test_users( + ["t1, t2"], + ["r1", "r2"], + custom_attributes={"ak": "av"}, + statuses=["invited"], + phones=["+111111"], + emails=["a@b.com"], + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1435,34 +1279,30 @@ def test_search_all_test_users(self): "phones": ["+111111"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with time parameters - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all_test_users( - from_created_time=100, - to_created_time=200, - from_modified_time=300, - to_modified_time=400, - limit=10, - page=0, + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all_test_users( + from_created_time=100, + to_created_time=200, + from_modified_time=300, + to_modified_time=400, + limit=10, + page=0, + ) ) users = resp["users"] - self.assertEqual(len(users), 1) - self.assertEqual(users[0]["id"], "u1") - # Verify the request body includes our time parameters - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", + assert len(users) == 1 + assert users[0]["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1478,30 +1318,27 @@ def test_search_all_test_users(self): "toModifiedTime": 400, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) # Test success flow with tenant_role_ids and tenant_role_names - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}, {"id": "u2"}]}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.search_all_test_users( - tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, - tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, + with client.mock_mgmt_post(make_response({"users": [{"id": "u1"}, {"id": "u2"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.search_all_test_users( + tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}}, + tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}}, + ) ) users = resp["users"] - self.assertEqual(len(users), 2) - self.assertEqual(users[0]["id"], "u1") - self.assertEqual(users[1]["id"], "u2") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", + assert len(users) == 2 + assert users[0]["id"] == "u1" + assert users[1]["id"] == "u2" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.test_users_search_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1515,41 +1352,43 @@ def test_search_all_test_users(self): "tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}}, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_get_provider_token(self): + async def test_get_provider_token(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.get") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.get_provider_token, - "valid-id", - "p1", - ) - # Test success flow - with patch("httpx.get") as mock_get: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"provider": "p1", "providerUserId": "puid", "accessToken": "access123", "refreshToken": "refresh456", "expiration": "123123123", "scopes": ["s1", "s2"]}""" - ) - mock_get.return_value = network_resp - resp = self.client.mgmt.user.get_provider_token("valid-id", "p1", True, True) - self.assertEqual(resp["provider"], "p1") - self.assertEqual(resp["providerUserId"], "puid") - self.assertEqual(resp["accessToken"], "access123") - self.assertEqual(resp["refreshToken"], "refresh456") - self.assertEqual(resp["expiration"], "123123123") - self.assertEqual(resp["scopes"], ["s1", "s2"]) - mock_get.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_get_provider_token}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.get_provider_token("valid-id", "p1")) + + # Test success flow + with client.mock_mgmt_get( + make_response({ + "provider": "p1", + "providerUserId": "puid", + "accessToken": "access123", + "refreshToken": "refresh456", + "expiration": "123123123", + "scopes": ["s1", "s2"], + }) + ) as mock_get: + resp = await client.invoke( + client.mgmt.user.get_provider_token("valid-id", "p1", True, True) + ) + assert resp["provider"] == "p1" + assert resp["providerUserId"] == "puid" + assert resp["accessToken"] == "access123" + assert resp["refreshToken"] == "refresh456" + assert resp["expiration"] == "123123123" + assert resp["scopes"] == ["s1", "s2"] + assert_http_called( + mock_get, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_get_provider_token}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params={ "loginId": "valid-id", @@ -1558,35 +1397,28 @@ def test_get_provider_token(self): "forceRefresh": True, }, follow_redirects=True, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_activate(self): + async def test_activate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.activate, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.activate("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.activate("valid-id") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.activate("valid-id")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1594,35 +1426,28 @@ def test_activate(self): "status": "enabled", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_deactivate(self): + async def test_deactivate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.deactivate, - "valid-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.deactivate("valid-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.deactivate("valid-id") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.deactivate("valid-id")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_status_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1630,36 +1455,28 @@ def test_deactivate(self): "status": "disabled", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_login_id(self): + async def test_update_login_id(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_login_id, - "valid-id", - "a@b.c", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_login_id("valid-id", "a@b.c")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "a@b.c"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_login_id("valid-id", "a@b.c") + with client.mock_mgmt_post(make_response({"user": {"id": "a@b.c"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_login_id("valid-id", "a@b.c")) user = resp["user"] - self.assertEqual(user["id"], "a@b.c") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_login_id_path}", + assert user["id"] == "a@b.c" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_login_id_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1667,36 +1484,28 @@ def test_update_login_id(self): "newLoginId": "a@b.c", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_email(self): + async def test_update_email(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_email, - "valid-id", - "a@b.c", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_email("valid-id", "a@b.c")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_email("valid-id", "a@b.c") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_email("valid-id", "a@b.c")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_email_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_email_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1706,36 +1515,28 @@ def test_update_email(self): "failOnConflict": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_phone(self): + async def test_update_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_phone, - "valid-id", - "+18005551234", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_phone("valid-id", "+18005551234")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_phone("valid-id", "+18005551234", True) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_phone("valid-id", "+18005551234", True)) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_phone_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_phone_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1745,36 +1546,28 @@ def test_update_phone(self): "failOnConflict": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_display_name(self): + async def test_update_display_name(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_display_name, - "valid-id", - "foo", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_display_name("valid-id", "foo")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_display_name("valid-id", "foo") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_display_name("valid-id", "foo")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_name_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_name_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1782,36 +1575,28 @@ def test_update_display_name(self): "displayName": "foo", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_picture(self): + async def test_update_picture(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_picture, - "valid-id", - "foo", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_picture("valid-id", "foo")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_picture("valid-id", "foo") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_picture("valid-id", "foo")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_picture_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_picture_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1819,37 +1604,32 @@ def test_update_picture(self): "picture": "foo", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_custom_attribute(self): + async def test_update_custom_attribute(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.update_custom_attribute, - "valid-id", - "foo", - "bar", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.update_custom_attribute("valid-id", "foo", "bar") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update_custom_attribute("valid-id", "foo", "bar") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.update_custom_attribute("valid-id", "foo", "bar") + ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_custom_attribute_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_custom_attribute_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "valid-id", @@ -1858,36 +1638,28 @@ def test_update_custom_attribute(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_set_roles(self): + async def test_set_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_roles, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.set_roles("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.set_roles("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.set_roles("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1895,36 +1667,28 @@ def test_set_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_add_roles(self): + async def test_add_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.add_roles, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.add_roles("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.add_roles("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.add_roles("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1932,36 +1696,28 @@ def test_add_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_remove_roles(self): + async def test_remove_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_roles, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_roles("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.remove_roles("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.remove_roles("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -1969,36 +1725,28 @@ def test_remove_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_add_sso_apps(self): + async def test_add_sso_apps(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.add_sso_apps, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.add_sso_apps("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.add_sso_apps("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.add_sso_apps("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_add_sso_apps}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_add_sso_apps}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2006,36 +1754,28 @@ def test_add_sso_apps(self): "ssoAppIds": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_set_sso_apps(self): + async def test_set_sso_apps(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_sso_apps, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.set_sso_apps("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.set_sso_apps("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.set_sso_apps("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_sso_apps}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_sso_apps}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2043,36 +1783,28 @@ def test_set_sso_apps(self): "ssoAppIds": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_remove_sso_apps(self): + async def test_remove_sso_apps(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_sso_apps, - "valid-id", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_sso_apps("valid-id", ["foo", "bar"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.remove_sso_apps("valid-id", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.remove_sso_apps("valid-id", ["foo", "bar"])) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_sso_apps}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_sso_apps}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2080,36 +1812,28 @@ def test_remove_sso_apps(self): "ssoAppIds": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_add_tenant(self): + async def test_add_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.add_tenant, - "valid-id", - "tid", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.add_tenant("valid-id", "tid")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.add_tenant("valid-id", "tid") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.add_tenant("valid-id", "tid")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_add_tenant_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_add_tenant_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2117,36 +1841,28 @@ def test_add_tenant(self): "tenantId": "tid", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_remove_tenant(self): + async def test_remove_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_tenant, - "valid-id", - "tid", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_tenant("valid-id", "tid")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.remove_tenant("valid-id", "tid") + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.remove_tenant("valid-id", "tid")) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_tenant_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_tenant_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2154,37 +1870,32 @@ def test_remove_tenant(self): "tenantId": "tid", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_set_tenant_roles(self): + async def test_set_tenant_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_tenant_roles, - "valid-id", - "tid", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.set_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.set_tenant_roles("valid-id", "tid", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.set_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2193,37 +1904,32 @@ def test_set_tenant_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_add_tenant_roles(self): + async def test_add_tenant_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.add_tenant_roles, - "valid-id", - "tid", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.add_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.add_tenant_roles("valid-id", "tid", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.add_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_add_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2232,37 +1938,32 @@ def test_add_tenant_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_remove_tenant_roles(self): + async def test_remove_tenant_roles(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_tenant_roles, - "valid-id", - "tid", - ["foo", "bar"], - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.remove_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.remove_tenant_roles("valid-id", "tid", ["foo", "bar"]) + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.remove_tenant_roles("valid-id", "tid", ["foo", "bar"]) + ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_role_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2271,37 +1972,35 @@ def test_remove_tenant_roles(self): "roleNames": ["foo", "bar"], }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_otp_for_test_user(self): + async def test_generate_otp_for_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.generate_otp_for_test_user, - "login-id", - "email", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.generate_otp_for_test_user("login-id", "email") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"code": "123456", "loginId": "login-id"}""") - mock_post.return_value = network_resp + with client.mock_mgmt_post( + make_response({"code": "123456", "loginId": "login-id"}) + ) as mock_post: login_options = LoginOptions(stepup=True) - resp = self.client.mgmt.user.generate_otp_for_test_user(DeliveryMethod.EMAIL, "login-id", login_options) - self.assertEqual(resp["code"], "123456") - self.assertEqual(resp["loginId"], "login-id") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_generate_otp_for_test_path}", + resp = await client.invoke( + client.mgmt.user.generate_otp_for_test_user(DeliveryMethod.EMAIL, "login-id", login_options) + ) + assert resp["code"] == "123456" + assert resp["loginId"] == "login-id" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_otp_for_test_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2314,36 +2013,33 @@ def test_generate_otp_for_test_user(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_set_temporary_password(self): + async def test_user_set_temporary_password(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_temporary_password, - "login-id", - "some-password", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.set_temporary_password("login-id", "some-password") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.set_temporary_password( - "login-id", - "some-password", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.set_temporary_password( + "login-id", + "some-password", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_temporary_password_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_temporary_password_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2352,36 +2048,33 @@ def test_user_set_temporary_password(self): "setActive": False, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_set_active_password(self): + async def test_user_set_active_password(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_active_password, - "login-id", - "some-password", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.set_active_password("login-id", "some-password") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.set_active_password( - "login-id", - "some-password", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.set_active_password( + "login-id", + "some-password", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_active_password_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_active_password_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2390,36 +2083,33 @@ def test_user_set_active_password(self): "setActive": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_set_password(self): + async def test_user_set_password(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.set_password, - "login-id", - "some-password", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.set_password("login-id", "some-password") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.set_password( - "login-id", - "some-password", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.set_password( + "login-id", + "some-password", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_set_password_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_set_password_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2427,142 +2117,127 @@ def test_user_set_password(self): "password": "some-password", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_expire_password(self): + async def test_user_expire_password(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.expire_password, - "login-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.expire_password("login-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.expire_password( - "login-id", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.expire_password( + "login-id", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_expire_password_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_expire_password_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "loginId": "login-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_remove_all_passkeys(self): + async def test_user_remove_all_passkeys(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_all_passkeys, - "login-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_all_passkeys("login-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.remove_all_passkeys( - "login-id", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.remove_all_passkeys( + "login-id", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_all_passkeys_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_all_passkeys_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "loginId": "login-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_user_remove_totp_seed(self): + async def test_user_remove_totp_seed(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.remove_totp_seed, - "login-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_totp_seed("login-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - mock_post.return_value = network_resp - self.client.mgmt.user.remove_totp_seed( - "login-id", + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.user.remove_totp_seed( + "login-id", + ) ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_remove_totp_seed_path}", + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_totp_seed_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ "loginId": "login-id", }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_magic_link_for_test_user(self): + async def test_generate_magic_link_for_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.generate_magic_link_for_test_user, - "login-id", - "email", - "bla", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.generate_magic_link_for_test_user("login-id", "email", "bla") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"link": "some-link", "loginId": "login-id"}""") - mock_post.return_value = network_resp + with client.mock_mgmt_post( + make_response({"link": "some-link", "loginId": "login-id"}) + ) as mock_post: login_options = LoginOptions(stepup=True) - resp = self.client.mgmt.user.generate_magic_link_for_test_user( - DeliveryMethod.EMAIL, "login-id", "bla", login_options + resp = await client.invoke( + client.mgmt.user.generate_magic_link_for_test_user( + DeliveryMethod.EMAIL, "login-id", "bla", login_options + ) ) - self.assertEqual(resp["link"], "some-link") - self.assertEqual(resp["loginId"], "login-id") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_generate_magic_link_for_test_path}", + assert resp["link"] == "some-link" + assert resp["loginId"] == "login-id" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_magic_link_for_test_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2576,40 +2251,36 @@ def test_generate_magic_link_for_test_user(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_enchanted_link_for_test_user(self): + async def test_generate_enchanted_link_for_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.generate_enchanted_link_for_test_user, - "login-id", - "bla", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.generate_enchanted_link_for_test_user("login-id", "bla") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """{"link": "some-link", "loginId": "login-id", "pendingRef": "some-ref"}""" - ) - mock_post.return_value = network_resp + with client.mock_mgmt_post( + make_response({"link": "some-link", "loginId": "login-id", "pendingRef": "some-ref"}) + ) as mock_post: login_options = LoginOptions(stepup=True) - resp = self.client.mgmt.user.generate_enchanted_link_for_test_user("login-id", "bla", login_options) - self.assertEqual(resp["link"], "some-link") - self.assertEqual(resp["loginId"], "login-id") - self.assertEqual(resp["pendingRef"], "some-ref") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_generate_enchanted_link_for_test_path}", + resp = await client.invoke( + client.mgmt.user.generate_enchanted_link_for_test_user("login-id", "bla", login_options) + ) + assert resp["link"] == "some-link" + assert resp["loginId"] == "login-id" + assert resp["pendingRef"] == "some-ref" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_enchanted_link_for_test_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2622,30 +2293,29 @@ def test_generate_enchanted_link_for_test_user(self): }, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_embedded_link(self): + async def test_generate_embedded_link(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, self.client.mgmt.user.generate_embedded_link, "login-id") + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.generate_embedded_link("login-id")) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"token": "some-token"}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.generate_embedded_link("login-id", {"k1": "v1"}) - self.assertEqual(resp, "some-token") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_generate_embedded_link_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + with client.mock_mgmt_post(make_response({"token": "some-token"})) as mock_post: + resp = await client.invoke( + client.mgmt.user.generate_embedded_link("login-id", {"k1": "v1"}) + ) + assert resp == "some-token" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_embedded_link_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "login-id", @@ -2654,36 +2324,33 @@ def test_generate_embedded_link(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_generate_sign_up_embedded_link(self): + async def test_generate_sign_up_embedded_link(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises( - AuthException, - self.client.mgmt.user.generate_sign_up_embedded_link, - "login-id", - ) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.user.generate_sign_up_embedded_link("login-id") + ) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads("""{"token": "some-token"}""") - mock_post.return_value = network_resp - resp = self.client.mgmt.user.generate_sign_up_embedded_link( - "login-id", email_verified=True, phone_verified=True - ) - self.assertEqual(resp, "some-token") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_generate_sign_up_embedded_link_path}", - headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + with client.mock_mgmt_post(make_response({"token": "some-token"})) as mock_post: + resp = await client.invoke( + client.mgmt.user.generate_sign_up_embedded_link( + "login-id", email_verified=True, phone_verified=True + ) + ) + assert resp == "some-token" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_generate_sign_up_embedded_link_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json={ "loginId": "login-id", @@ -2695,94 +2362,85 @@ def test_generate_sign_up_embedded_link(self): }, follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_history(self): + async def test_history(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + # Test failed flows - with patch("httpx.post") as mock_post: - mock_post.return_value.is_success = False - self.assertRaises(AuthException, self.client.mgmt.user.history, ["user-id-1", "user-id-2"]) + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.history(["user-id-1", "user-id-2"])) # Test success flow - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32 - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23 - } - ] - """ - ) - mock_post.return_value = network_resp - resp = self.client.mgmt.user.history(["user-id-1", "user-id-2"]) - self.assertEqual( - resp, - [ - { - "userId": "kuku", - "city": "kefar saba", - "country": "Israel", - "ip": "1.1.1.1", - "loginTime": 32, - }, - { - "userId": "nunu", - "city": "eilat", - "country": "Israele", - "ip": "1.1.1.2", - "loginTime": 23, - }, - ], - ) - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_history_path}", + with client.mock_mgmt_post( + make_response([ + { + "userId": "kuku", + "city": "kefar saba", + "country": "Israel", + "ip": "1.1.1.1", + "loginTime": 32, + }, + { + "userId": "nunu", + "city": "eilat", + "country": "Israele", + "ip": "1.1.1.2", + "loginTime": 23, + }, + ]) + ) as mock_post: + resp = await client.invoke(client.mgmt.user.history(["user-id-1", "user-id-2"])) + assert resp == [ + { + "userId": "kuku", + "city": "kefar saba", + "country": "Israel", + "ip": "1.1.1.1", + "loginTime": 32, + }, + { + "userId": "nunu", + "city": "eilat", + "country": "Israele", + "ip": "1.1.1.2", + "loginTime": 23, + }, + ] + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_history_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, json=["user-id-1", "user-id-2"], follow_redirects=False, params=None, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_test_user(self): - with patch("httpx.post") as mock_post: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads('{"user": {"id": "u1"}}') - mock_post.return_value = network_resp - resp = self.client.mgmt.user.update( - "id", - display_name="test-user", - test=True, + async def test_update_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke( + client.mgmt.user.update( + "id", + display_name="test-user", + test=True, + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_update_path}", + assert user["id"] == "u1" + assert_http_called( + mock_post, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2799,29 +2457,28 @@ def test_update_test_user(self): "ssoAppIDs": None, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_patch_test_user(self): - with patch("httpx.patch") as mock_patch: - network_resp = mock.Mock() - network_resp.is_success = True - network_resp.json.return_value = json.loads('{"user": {"id": "u1"}}') - mock_patch.return_value = network_resp - resp = self.client.mgmt.user.patch( - "id", - display_name="test-user", - test=True, + async def test_patch_test_user(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_patch(make_response({"user": {"id": "u1"}})) as mock_patch: + resp = await client.invoke( + client.mgmt.user.patch( + "id", + display_name="test-user", + test=True, + ) ) user = resp["user"] - self.assertEqual(user["id"], "u1") - mock_patch.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + assert user["id"] == "u1" + assert_http_called( + mock_patch, client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", headers={ - **common.default_headers, - "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", - "x-descope-project-id": self.dummy_project_id, + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, }, params=None, json={ @@ -2830,6 +2487,4 @@ def test_patch_test_user(self): "test": True, }, follow_redirects=False, - verify=SSLMatcher(), - timeout=DEFAULT_TIMEOUT_SECONDS, ) From dbc36c78db2bdce1ea8cab4e4c8bfb676c83c3a3 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:48:20 +0300 Subject: [PATCH 14/17] and another pass --- descope/authmethod/_enchantedlink_base.py | 15 +- descope/authmethod/_magiclink_base.py | 15 +- descope/authmethod/_oauth_base.py | 7 + descope/authmethod/_otp_base.py | 10 +- descope/authmethod/_password_base.py | 36 ++- descope/authmethod/_saml_base.py | 5 + descope/authmethod/_sso_base.py | 5 + descope/authmethod/_webauthn_base.py | 30 ++- descope/authmethod/enchantedlink.py | 6 +- descope/authmethod/enchantedlink_async.py | 6 +- descope/authmethod/magiclink.py | 13 +- descope/authmethod/magiclink_async.py | 9 +- descope/authmethod/oauth.py | 3 +- descope/authmethod/oauth_async.py | 3 +- descope/authmethod/otp.py | 15 +- descope/authmethod/otp_async.py | 15 +- descope/authmethod/password.py | 40 +--- descope/authmethod/password_async.py | 40 +--- descope/authmethod/saml.py | 4 +- descope/authmethod/saml_async.py | 4 +- descope/authmethod/sso.py | 4 +- descope/authmethod/sso_async.py | 4 +- descope/authmethod/webauthn.py | 50 ++--- descope/authmethod/webauthn_async.py | 50 ++--- descope/management/_access_key_base.py | 41 ++++ descope/management/_audit_base.py | 31 +++ descope/management/_jwt_base.py | 54 +++++ descope/management/_sso_application_base.py | 90 ++++++++ descope/management/_sso_settings_base.py | 229 ++++++++++++++++++++ descope/management/_user_base.py | 8 + descope/management/access_key.py | 27 +-- descope/management/access_key_async.py | 27 +-- descope/management/audit.py | 19 +- descope/management/audit_async.py | 19 +- descope/management/jwt.py | 37 +--- descope/management/jwt_async.py | 37 +--- descope/management/sso_application.py | 75 +------ descope/management/sso_application_async.py | 75 +------ descope/management/sso_settings.py | 206 +----------------- descope/management/sso_settings_async.py | 210 +----------------- descope/management/user.py | 12 +- descope/management/user_async.py | 12 +- 42 files changed, 687 insertions(+), 911 deletions(-) create mode 100644 descope/management/_access_key_base.py create mode 100644 descope/management/_audit_base.py create mode 100644 descope/management/_jwt_base.py create mode 100644 descope/management/_sso_application_base.py create mode 100644 descope/management/_sso_settings_base.py diff --git a/descope/authmethod/_enchantedlink_base.py b/descope/authmethod/_enchantedlink_base.py index 2bf481dcd..d3552ceef 100644 --- a/descope/authmethod/_enchantedlink_base.py +++ b/descope/authmethod/_enchantedlink_base.py @@ -9,18 +9,29 @@ SignUpOptions, signup_options_to_dict, ) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class EnchantedLinkBase: """Shared, I/O-free base for EnchantedLink auth-method classes. - Holds only static URL composers and body builders — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: + Holds only static validation guards, URL composers and body builders — no + network I/O, no ``__init__``. The two concrete subclasses add the network layer: - ``EnchantedLink(EnchantedLinkBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) - ``EnchantedLinkAsync(EnchantedLinkBase, AsyncAuthBase)`` — async, uses ``self._http`` """ + @staticmethod + def _validate_sign_in_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") + + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + @staticmethod def _compose_signin_url() -> str: return Auth.compose_url(EndpointsV1.sign_in_auth_enchantedlink_path, DeliveryMethod.EMAIL) diff --git a/descope/authmethod/_magiclink_base.py b/descope/authmethod/_magiclink_base.py index db8166f06..cc2386ae7 100644 --- a/descope/authmethod/_magiclink_base.py +++ b/descope/authmethod/_magiclink_base.py @@ -9,18 +9,29 @@ SignUpOptions, signup_options_to_dict, ) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class MagicLinkBase: """Shared, I/O-free base for MagicLink auth-method classes. - Holds only static URL composers and body builders — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: + Holds only static validation guards, URL composers and body builders — no + network I/O, no ``__init__``. The two concrete subclasses add the network layer: - ``MagicLink(MagicLinkBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) - ``MagicLinkAsync(MagicLinkBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) """ + @staticmethod + def _validate_sign_in_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier is empty") + + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + @staticmethod def _compose_signin_url(method: DeliveryMethod) -> str: return Auth.compose_url(EndpointsV1.sign_in_auth_magiclink_path, method) diff --git a/descope/authmethod/_oauth_base.py b/descope/authmethod/_oauth_base.py index de73c0eb7..4484d2400 100644 --- a/descope/authmethod/_oauth_base.py +++ b/descope/authmethod/_oauth_base.py @@ -1,6 +1,8 @@ # This is not part of the public API but a code helper from __future__ import annotations +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + class OAuthBase: """Shared, I/O-free base for OAuth auth-method classes. @@ -12,6 +14,11 @@ class OAuthBase: - ``OAuthAsync(OAuthBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) """ + @staticmethod + def _validate_exchange_code(code: str) -> None: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + @staticmethod def _verify_provider(oauth_provider: str) -> bool: if oauth_provider == "" or oauth_provider is None: diff --git a/descope/authmethod/_otp_base.py b/descope/authmethod/_otp_base.py index 74c1b7943..1d50c2fa1 100644 --- a/descope/authmethod/_otp_base.py +++ b/descope/authmethod/_otp_base.py @@ -9,18 +9,24 @@ SignUpOptions, signup_options_to_dict, ) +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class OTPBase: """Shared, I/O-free base for OTP auth-method classes. - Holds only static URL composers and body builders — no network I/O, no - ``__init__``. The two concrete subclasses add the network layer: + Holds only static validation guards, URL composers and body builders — no + network I/O, no ``__init__``. The two concrete subclasses add the network layer: - ``OTP(OTPBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) - ``OTPAsync(OTPBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) """ + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + @staticmethod def _compose_signup_url(method: DeliveryMethod) -> str: return Auth.compose_url(EndpointsV1.sign_up_auth_otp_path, method) diff --git a/descope/authmethod/_password_base.py b/descope/authmethod/_password_base.py index 1ed479d88..a2e392015 100644 --- a/descope/authmethod/_password_base.py +++ b/descope/authmethod/_password_base.py @@ -1,17 +1,49 @@ # This is not part of the public API but a code helper from __future__ import annotations +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException + class PasswordBase: """Shared, I/O-free base for Password auth-method classes. - Holds only static body composers — no network I/O, no ``__init__``. - The two concrete subclasses add the network layer: + Holds only static validation guards and body composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: - ``Password(PasswordBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) - ``PasswordAsync(PasswordBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) """ + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + @staticmethod + def _validate_password(password: str) -> None: + if not password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty") + + @staticmethod + def _validate_sign_in_password(password: str) -> None: + if not password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty") + + @staticmethod + def _validate_new_password(new_password: str) -> None: + if not new_password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") + + @staticmethod + def _validate_old_password(old_password: str) -> None: + if not old_password: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty") + + @staticmethod + def _validate_refresh_token(refresh_token: str) -> None: + if not refresh_token: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + @staticmethod def _compose_signup_body(login_id: str, password: str, user: dict | None) -> dict: body: dict[str, str | bool | dict] = {"loginId": login_id, "password": password} diff --git a/descope/authmethod/_saml_base.py b/descope/authmethod/_saml_base.py index a2d34dc96..38b5172eb 100644 --- a/descope/authmethod/_saml_base.py +++ b/descope/authmethod/_saml_base.py @@ -24,6 +24,11 @@ def _validate_return_url(return_url: str) -> None: if not return_url: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Return url cannot be empty") + @staticmethod + def _validate_exchange_code(code: str) -> None: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + @staticmethod def _compose_start_params(tenant: str, return_url: str) -> dict: res: dict = {"tenant": tenant} diff --git a/descope/authmethod/_sso_base.py b/descope/authmethod/_sso_base.py index e1bde6544..3738234fd 100644 --- a/descope/authmethod/_sso_base.py +++ b/descope/authmethod/_sso_base.py @@ -21,6 +21,11 @@ def _validate_tenant(tenant: str) -> None: if not tenant: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") + @staticmethod + def _validate_exchange_code(code: str) -> None: + if not code: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + @staticmethod def _compose_start_params( tenant: str, diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py index bef9cbcf6..68ba44ead 100644 --- a/descope/authmethod/_webauthn_base.py +++ b/descope/authmethod/_webauthn_base.py @@ -4,18 +4,44 @@ from typing import Optional from descope.common import LoginOptions +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class WebAuthnBase: """Shared, I/O-free base for WebAuthn auth-method classes. - Holds only static body composers — no network I/O, no ``__init__``. - The two concrete subclasses add the network layer: + Holds only static validation guards and body composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: - ``WebAuthn(WebAuthnBase, AuthBase)`` — sync, uses ``self._http`` (``HTTPClient``) - ``WebAuthnAsync(WebAuthnBase, AsyncAuthBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) """ + @staticmethod + def _validate_login_id(login_id: Optional[str]) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + + @staticmethod + def _validate_origin(origin: Optional[str]) -> None: + if not origin: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + + @staticmethod + def _validate_transaction_id(transaction_id: str) -> None: + if not transaction_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") + + @staticmethod + def _validate_response(response) -> None: + if not response: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + + @staticmethod + def _validate_refresh_token(refresh_token: str) -> None: + if not refresh_token: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + @staticmethod def _compose_sign_up_start_body(login_id: str, user: dict, origin: str) -> dict: user.update({"loginId": login_id}) diff --git a/descope/authmethod/enchantedlink.py b/descope/authmethod/enchantedlink.py index 0128d444b..06ab4d936 100644 --- a/descope/authmethod/enchantedlink.py +++ b/descope/authmethod/enchantedlink.py @@ -22,8 +22,7 @@ def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") + self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -91,8 +90,7 @@ def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index 353f241b8..bd43e9245 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -24,8 +24,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id is empty") + self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -93,8 +92,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) diff --git a/descope/authmethod/magiclink.py b/descope/authmethod/magiclink.py index 92962c281..7d04ae34f 100644 --- a/descope/authmethod/magiclink.py +++ b/descope/authmethod/magiclink.py @@ -25,12 +25,7 @@ def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> str: - if not login_id: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - "Identifier is empty", - ) + self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -106,8 +101,7 @@ def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) @@ -136,8 +130,7 @@ def update_user_phone( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/magiclink_async.py b/descope/authmethod/magiclink_async.py index a21430a98..67984f032 100644 --- a/descope/authmethod/magiclink_async.py +++ b/descope/authmethod/magiclink_async.py @@ -27,8 +27,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier is empty") + self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -97,8 +96,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) @@ -127,8 +125,7 @@ async def update_user_phone( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/oauth.py b/descope/authmethod/oauth.py index d77ffc633..8a4e9b7ca 100644 --- a/descope/authmethod/oauth.py +++ b/descope/authmethod/oauth.py @@ -43,8 +43,7 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.oauth_exchange_token_path body = self._compose_exchange_body(code) response = self._http.post(uri, body=body) diff --git a/descope/authmethod/oauth_async.py b/descope/authmethod/oauth_async.py index 5891b3091..44592b764 100644 --- a/descope/authmethod/oauth_async.py +++ b/descope/authmethod/oauth_async.py @@ -43,8 +43,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.oauth_exchange_token_path body = self._compose_exchange_body(code) response = await self._http.post(uri, body=body) diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index a5ab20d05..4727318c7 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -39,8 +39,7 @@ def sign_in( Raise: AuthException: raised if sign-in operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -105,8 +104,7 @@ def sign_up_or_in( Raise: AuthException: raised if either the sign_up or sign_in operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) uri = OTP._compose_sign_up_or_in_url(method) login_options: LoginOptions | None = None @@ -147,8 +145,7 @@ def verify_code( Raise: AuthException: raised if the OTP code is not valid or if token verification failed """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) uri = OTP._compose_verify_code_url(method) body = OTP._compose_verify_code_body(login_id, code) @@ -183,8 +180,7 @@ def update_user_email( AuthException: raised if OTP verification fails or if token verification fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) @@ -229,8 +225,7 @@ def update_user_phone( AuthException: raised if OTP verification fails or if token verification fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/otp_async.py b/descope/authmethod/otp_async.py index 8943ca951..14a10ae78 100644 --- a/descope/authmethod/otp_async.py +++ b/descope/authmethod/otp_async.py @@ -26,8 +26,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -64,8 +63,7 @@ async def sign_up_or_in( login_id: str, signup_options: SignUpOptions | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) uri = self._compose_sign_up_or_in_url(method) login_options: LoginOptions | None = None @@ -86,8 +84,7 @@ async def verify_code( code: str, audience: str | None | Iterable[str] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) uri = self._compose_verify_code_url(method) body = self._compose_verify_code_body(login_id, code) @@ -107,8 +104,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_email(email) @@ -137,8 +133,7 @@ async def update_user_phone( template_id: str | None = None, provider_id: str | None = None, ) -> str: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") + self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/password.py b/descope/authmethod/password.py index 851a88b96..ce65322e8 100644 --- a/descope/authmethod/password.py +++ b/descope/authmethod/password.py @@ -5,7 +5,6 @@ from descope._auth_base import AuthBase from descope.authmethod._password_base import PasswordBase from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class Password(PasswordBase, AuthBase): @@ -35,11 +34,8 @@ def sign_up( AuthException: raised if sign-up operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty") + self._validate_login_id(login_id) + self._validate_password(password) uri = EndpointsV1.sign_up_password_path body = Password._compose_signup_body(login_id, password, user) @@ -73,11 +69,8 @@ def sign_in( AuthException: raised if sign in operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty") + self._validate_login_id(login_id) + self._validate_sign_in_password(password) uri = EndpointsV1.sign_in_password_path response = self._http.post(uri, body={"loginId": login_id, "password": password}) @@ -115,8 +108,7 @@ def send_reset( AuthException: raised if send reset operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) uri = EndpointsV1.send_reset_password_path body: dict[str, str | bool | dict | None] = { @@ -143,14 +135,9 @@ def update(self, login_id: str, new_password: str, refresh_token: str) -> None: AuthException: raised if refresh token is invalid or update operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not new_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") - - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + self._validate_login_id(login_id) + self._validate_new_password(new_password) + self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_password_path self._http.post( @@ -185,14 +172,9 @@ def replace( AuthException: raised if replace operation fails """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not old_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty") - - if not new_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") + self._validate_login_id(login_id) + self._validate_old_password(old_password) + self._validate_new_password(new_password) uri = EndpointsV1.replace_password_path response = self._http.post( diff --git a/descope/authmethod/password_async.py b/descope/authmethod/password_async.py index 096426b32..7f6eebfc9 100644 --- a/descope/authmethod/password_async.py +++ b/descope/authmethod/password_async.py @@ -5,7 +5,6 @@ from descope._auth_base import AsyncAuthBase from descope.authmethod._password_base import PasswordBase from descope.common import REFRESH_SESSION_COOKIE_NAME, EndpointsV1 -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class PasswordAsync(PasswordBase, AsyncAuthBase): @@ -18,11 +17,8 @@ async def sign_up( user: dict | None = None, audience: str | None | Iterable[str] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "password cannot be empty") + self._validate_login_id(login_id) + self._validate_password(password) uri = EndpointsV1.sign_up_password_path body = self._compose_signup_body(login_id, password, user) @@ -37,11 +33,8 @@ async def sign_in( password: str, audience: str | None | Iterable[str] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Password cannot be empty") + self._validate_login_id(login_id) + self._validate_sign_in_password(password) uri = EndpointsV1.sign_in_password_path response = await self._http.post(uri, body={"loginId": login_id, "password": password}) @@ -55,8 +48,7 @@ async def send_reset( redirect_url: str | None = None, template_options: dict | None = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) uri = EndpointsV1.send_reset_password_path body: dict[str, str | bool | dict | None] = { @@ -70,14 +62,9 @@ async def send_reset( return response.json() async def update(self, login_id: str, new_password: str, refresh_token: str) -> None: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not new_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") - - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + self._validate_login_id(login_id) + self._validate_new_password(new_password) + self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_password_path await self._http.post( @@ -93,14 +80,9 @@ async def replace( new_password: str, audience: str | None | Iterable[str] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") - - if not old_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "old_password cannot be empty") - - if not new_password: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "new_password cannot be empty") + self._validate_login_id(login_id) + self._validate_old_password(old_password) + self._validate_new_password(new_password) uri = EndpointsV1.replace_password_path response = await self._http.post( diff --git a/descope/authmethod/saml.py b/descope/authmethod/saml.py index 0e78e6c87..5412e3efa 100644 --- a/descope/authmethod/saml.py +++ b/descope/authmethod/saml.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # This class is DEPRECATED please use SSO instead @@ -42,8 +41,7 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.saml_exchange_token_path body = self._compose_exchange_body(code) response = self._http.post(uri, body=body) diff --git a/descope/authmethod/saml_async.py b/descope/authmethod/saml_async.py index 009e88baa..56a8df079 100644 --- a/descope/authmethod/saml_async.py +++ b/descope/authmethod/saml_async.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException # This class is DEPRECATED please use SSOAsync instead @@ -40,8 +39,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.saml_exchange_token_path body = self._compose_exchange_body(code) response = await self._http.post(uri, body=body) diff --git a/descope/authmethod/sso.py b/descope/authmethod/sso.py index 2d90e4ab6..f2d7e60f0 100644 --- a/descope/authmethod/sso.py +++ b/descope/authmethod/sso.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class SSO(SSOBase, AuthBase): @@ -65,8 +64,7 @@ def start( return response.json() def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.sso_exchange_token_path body = self._compose_exchange_body(code) response = self._http.post(uri, body=body) diff --git a/descope/authmethod/sso_async.py b/descope/authmethod/sso_async.py index 52b8334a8..b59a38549 100644 --- a/descope/authmethod/sso_async.py +++ b/descope/authmethod/sso_async.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class SSOAsync(SSOBase, AsyncAuthBase): @@ -49,8 +48,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: - if not code: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") + self._validate_exchange_code(code) uri = EndpointsV1.sso_exchange_token_path body = self._compose_exchange_body(code) response = await self._http.post(uri, body=body) diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index f0415e83a..128fb5b70 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class WebAuthn(WebAuthnBase, AuthBase): @@ -23,11 +22,8 @@ def sign_up_start( """ Docs """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) if not user: user = {} @@ -46,11 +42,8 @@ def sign_up_finish( """ Docs """ - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.sign_up_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) response = self._http.post(uri, body=body) @@ -67,11 +60,8 @@ def sign_in_start( """ Docs """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) validate_refresh_token_provided(login_options, refresh_token) @@ -89,11 +79,8 @@ def sign_in_finish( """ Docs """ - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.sign_in_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) @@ -109,11 +96,8 @@ def sign_up_or_in_start( """ Docs """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path body = self._compose_sign_up_or_in_start_body(login_id, origin) @@ -124,11 +108,8 @@ def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: """ Docs """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + self._validate_login_id(login_id) + self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path body = self._compose_update_start_body(login_id, origin) @@ -139,11 +120,8 @@ def update_finish(self, transaction_id: str, response: str) -> None: """ Docs """ - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.update_auth_webauthn_finish_path body = self._compose_update_finish_body(transaction_id, response) diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py index 7ac4afd66..ba9c3d00a 100644 --- a/descope/authmethod/webauthn_async.py +++ b/descope/authmethod/webauthn_async.py @@ -10,7 +10,6 @@ LoginOptions, validate_refresh_token_provided, ) -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException class WebAuthnAsync(WebAuthnBase, AsyncAuthBase): @@ -22,11 +21,8 @@ async def sign_up_start( origin: Optional[str], user: Optional[dict] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) if not user: user = {} @@ -42,11 +38,8 @@ async def sign_up_finish( response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.sign_up_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) @@ -61,11 +54,8 @@ async def sign_in_start( login_options: Optional[LoginOptions] = None, refresh_token: Optional[str] = None, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) validate_refresh_token_provided(login_options, refresh_token) @@ -80,11 +70,8 @@ async def sign_in_finish( response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.sign_in_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) @@ -97,11 +84,8 @@ async def sign_up_or_in_start( login_id: str, origin: str, ) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not origin: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Origin cannot be empty") + self._validate_login_id(login_id) + self._validate_origin(origin) uri = EndpointsV1.sign_up_or_in_auth_webauthn_start_path body = self._compose_sign_up_or_in_start_body(login_id, origin) @@ -109,11 +93,8 @@ async def sign_up_or_in_start( return response.json() async def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Identifier cannot be empty") - - if not refresh_token: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") + self._validate_login_id(login_id) + self._validate_refresh_token(refresh_token) uri = EndpointsV1.update_auth_webauthn_start_path body = self._compose_update_start_body(login_id, origin) @@ -121,11 +102,8 @@ async def update_start(self, login_id: str, refresh_token: str, origin: str) -> return response.json() async def update_finish(self, transaction_id: str, response: str) -> None: - if not transaction_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Transaction id cannot be empty") - - if not response: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Response cannot be empty") + self._validate_transaction_id(transaction_id) + self._validate_response(response) uri = EndpointsV1.update_auth_webauthn_finish_path body = self._compose_update_finish_body(transaction_id, response) diff --git a/descope/management/_access_key_base.py b/descope/management/_access_key_base.py new file mode 100644 index 000000000..8c9362f31 --- /dev/null +++ b/descope/management/_access_key_base.py @@ -0,0 +1,41 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import List, Optional + +from descope.management.common import AssociatedTenant, associated_tenants_to_dict + + +class AccessKeyBase: + """Shared, I/O-free base for AccessKey management classes. + + Holds only static body composers — no network I/O, no ``__init__``. + The two concrete subclasses add the network layer: + + - ``AccessKey(AccessKeyBase, HTTPBase)`` — sync + - ``AccessKeyAsync(AccessKeyBase, AsyncHTTPBase)`` — async + """ + + @staticmethod + def _compose_create_body( + name: str, + expire_time: int, + role_names: List[str], + key_tenants: List[AssociatedTenant], + user_id: Optional[str] = None, + custom_claims: Optional[dict] = None, + description: Optional[str] = None, + permitted_ips: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + ) -> dict: + return { + "name": name, + "expireTime": expire_time, + "roleNames": role_names, + "keyTenants": associated_tenants_to_dict(key_tenants), + "userId": user_id, + "customClaims": custom_claims, + "description": description, + "permittedIps": permitted_ips, + "customAttributes": custom_attributes, + } diff --git a/descope/management/_audit_base.py b/descope/management/_audit_base.py new file mode 100644 index 000000000..eefa67109 --- /dev/null +++ b/descope/management/_audit_base.py @@ -0,0 +1,31 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from datetime import datetime + + +class AuditBase: + """Shared, I/O-free base for Audit management classes. + + Holds only static converters — no network I/O, no ``__init__``. + The two concrete subclasses add the network layer: + + - ``Audit(AuditBase, HTTPBase)`` — sync + - ``AuditAsync(AuditBase, AsyncHTTPBase)`` — async + """ + + @staticmethod + def _convert_audit_record(a: dict) -> dict: + return { + "projectId": a.get("projectId", ""), + "userId": a.get("userId", ""), + "action": a.get("action", ""), + "occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000), + "device": a.get("device", ""), + "method": a.get("method", ""), + "geo": a.get("geo", ""), + "remoteAddress": a.get("remoteAddress", ""), + "loginIds": a.get("externalIds", []), + "tenants": a.get("tenants", []), + "data": a.get("data", {}), + } diff --git a/descope/management/_jwt_base.py b/descope/management/_jwt_base.py new file mode 100644 index 000000000..3334a1ea5 --- /dev/null +++ b/descope/management/_jwt_base.py @@ -0,0 +1,54 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import Optional + +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.management.common import MgmtUserRequest, MgmtSignUpOptions + + +class JWTBase: + """Shared, I/O-free base for JWT management classes. + + Holds only static validation guards and body composers — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``JWT(JWTBase, HTTPBase)`` — sync, uses ``self._http`` (``HTTPClient``) + - ``JWTAsync(JWTBase, AsyncHTTPBase)`` — async, uses ``self._http`` (``HTTPClientAsync``) + """ + + @staticmethod + def _validate_jwt(jwt: str) -> None: + if not jwt: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") + + @staticmethod + def _validate_impersonator_id(impersonator_id: str) -> None: + if not impersonator_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "impersonator_id cannot be empty") + + @staticmethod + def _validate_login_id(login_id: str) -> None: + if not login_id: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + + @staticmethod + def _validate_jwt_required(login_options) -> None: + if not login_options.jwt: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") + + @staticmethod + def _compose_sign_up_body( + login_id: str, + user: MgmtUserRequest, + signup_options: MgmtSignUpOptions, + ) -> dict: + return { + "loginId": login_id, + "user": user.to_dict(), + "emailVerified": user.email_verified, + "phoneVerified": user.phone_verified, + "ssoAppId": user.sso_app_id, + "customClaims": signup_options.custom_claims, + "refreshDuration": signup_options.refresh_duration, + } diff --git a/descope/management/_sso_application_base.py b/descope/management/_sso_application_base.py new file mode 100644 index 000000000..82a92f1c7 --- /dev/null +++ b/descope/management/_sso_application_base.py @@ -0,0 +1,90 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import Any, List, Optional + +from descope.management.common import ( + SAMLIDPAttributeMappingInfo, + SAMLIDPGroupsMappingInfo, + saml_idp_attribute_mapping_info_to_dict, + saml_idp_groups_mapping_info_to_dict, +) + + +class SSOApplicationBase: + """Shared, I/O-free base for SSOApplication management classes. + + Holds only static body composers — no network I/O, no ``__init__``. + The two concrete subclasses add the network layer: + + - ``SSOApplication(SSOApplicationBase, HTTPBase)`` — sync + - ``SSOApplicationAsync(SSOApplicationBase, AsyncHTTPBase)`` — async + """ + + @staticmethod + def _compose_create_update_oidc_body( + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + force_authentication: Optional[bool] = False, + ) -> dict: + body: dict[str, Any] = { + "name": name, + "id": id, + "description": description, + "logo": logo, + "enabled": enabled, + "loginPageUrl": login_page_url, + "forceAuthentication": force_authentication, + } + return body + + @staticmethod + def _compose_create_update_saml_body( + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + enabled: Optional[bool] = True, + logo: Optional[str] = None, + use_metadata_info: Optional[bool] = False, + metadata_url: Optional[str] = None, + entity_id: Optional[str] = None, + acs_url: Optional[str] = None, + certificate: Optional[str] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + acs_allowed_callbacks: Optional[List[str]] = None, + subject_name_id_type: Optional[str] = None, + subject_name_id_format: Optional[str] = None, + default_relay_state: Optional[str] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + default_signature_algorithm: Optional[str] = None, + ) -> dict: + body: dict[str, Any] = { + "id": id, + "name": name, + "description": description, + "enabled": enabled, + "logo": logo, + "loginPageUrl": login_page_url, + "useMetadataInfo": use_metadata_info, + "metadataUrl": metadata_url, + "entityId": entity_id, + "acsUrl": acs_url, + "certificate": certificate, + "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), + "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), + "acsAllowedCallbacks": acs_allowed_callbacks, + "subjectNameIdType": subject_name_id_type, + "subjectNameIdFormat": subject_name_id_format, + "defaultRelayState": default_relay_state, + "forceAuthentication": force_authentication, + "logoutRedirectUrl": logout_redirect_url, + "defaultSignatureAlgorithm": default_signature_algorithm, + } + return body diff --git a/descope/management/_sso_settings_base.py b/descope/management/_sso_settings_base.py new file mode 100644 index 000000000..937462908 --- /dev/null +++ b/descope/management/_sso_settings_base.py @@ -0,0 +1,229 @@ +# This is not part of the public API but a code helper +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: + from descope.management.sso_settings import ( + AttributeMapping, + FGAGroupMapping, + RoleMapping, + SSOOIDCSettings, + SSOSAMLSettings, + SSOSAMLSettingsByMetadata, + ) + + +class SSOSettingsBase: + """Shared, I/O-free base for SSOSettings management classes. + + Holds only static body composers and dict converters — no network I/O, no + ``__init__``. The two concrete subclasses add the network layer: + + - ``SSOSettings(SSOSettingsBase, HTTPBase)`` — sync + - ``SSOSettingsAsync(SSOSettingsBase, AsyncHTTPBase)`` — async + """ + + @staticmethod + def _compose_configure_body( + tenant_id: str, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, + domains: Optional[List[str]], + ) -> dict: + return { + "tenantId": tenant_id, + "idpURL": idp_url, + "entityId": entity_id, + "idpCert": idp_cert, + "redirectURL": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_metadata_body( + tenant_id: str, + idp_metadata_url: str, + redirect_url: Optional[str] = None, + domains: Optional[List[str]] = None, + ) -> dict: + return { + "tenantId": tenant_id, + "idpMetadataURL": idp_metadata_url, + "redirectURL": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_mapping_body( + tenant_id: str, + role_mapping: Optional[List[RoleMapping]], + attribute_mapping: Optional[AttributeMapping], + ) -> dict: + return { + "tenantId": tenant_id, + "roleMappings": SSOSettingsBase._role_mapping_to_dict(role_mapping), + "attributeMapping": SSOSettingsBase._attribute_mapping_to_dict(attribute_mapping), + } + + @staticmethod + def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: + if role_mapping is None: + role_mapping = [] + role_mapping_list = [] + for mapping in role_mapping: + role_mapping_list.append( + { + "groups": mapping.groups, + "roleName": mapping.role_name, + } + ) + return role_mapping_list + + @staticmethod + def _attribute_mapping_to_dict( + attribute_mapping: Optional[AttributeMapping], + ) -> dict: + if attribute_mapping is None: + raise ValueError("Attribute mapping cannot be None") + return { + "name": attribute_mapping.name, + "email": attribute_mapping.email, + "phoneNumber": attribute_mapping.phone_number, + "group": attribute_mapping.group, + "givenName": attribute_mapping.given_name, + "middleName": attribute_mapping.middle_name, + "familyName": attribute_mapping.family_name, + "picture": attribute_mapping.picture, + "customAttributes": attribute_mapping.custom_attributes, + } + + @staticmethod + def _fga_mappings_to_dict( + fga_mappings: Optional[Dict[str, FGAGroupMapping]], + ) -> Optional[dict]: + if fga_mappings is None: + return None + result: dict = {} + for group_name, mapping in fga_mappings.items(): + relations = [] + if mapping is not None and mapping.relations: + for relation in mapping.relations: + relations.append( + { + "resource": relation.resource, + "relationDefinition": relation.relation_definition, + "namespace": relation.namespace, + } + ) + result[group_name] = {"relations": relations} + return result + + @staticmethod + def _compose_configure_oidc_settings_body( + tenant_id: str, + settings: SSOOIDCSettings, + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = { + "loginId": settings.attribute_mapping.login_id, + "name": settings.attribute_mapping.name, + "givenName": settings.attribute_mapping.given_name, + "middleName": settings.attribute_mapping.middle_name, + "familyName": settings.attribute_mapping.family_name, + "email": settings.attribute_mapping.email, + "verifiedEmail": settings.attribute_mapping.verified_email, + "username": settings.attribute_mapping.username, + "phoneNumber": settings.attribute_mapping.phone_number, + "verifiedPhone": settings.attribute_mapping.verified_phone, + "picture": settings.attribute_mapping.picture, + } + + return { + "tenantId": tenant_id, + "settings": { + "name": settings.name, + "clientId": settings.client_id, + "clientSecret": settings.client_secret, + "redirectUrl": settings.redirect_url, + "authUrl": settings.auth_url, + "tokenUrl": settings.token_url, + "userDataUrl": settings.user_data_url, + "scope": settings.scope, + "JWKsUrl": settings.jwks_url, + "userAttrMapping": attr_mapping, + "manageProviderTokens": settings.manage_provider_tokens, + "callbackDomain": settings.callback_domain, + "prompt": settings.prompt, + "grantType": settings.grant_type, + "issuer": settings.issuer, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), + }, + "domains": domains, + } + + @staticmethod + def _compose_configure_saml_settings_body( + tenant_id: str, + settings: SSOSAMLSettings, + redirect_url: Optional[str], + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) + + return { + "tenantId": tenant_id, + "settings": { + "idpUrl": settings.idp_url, + "entityId": settings.idp_entity_id, + "idpCert": settings.idp_cert, + "idpAdditionalCerts": settings.idp_additional_certs, + "spACSUrl": settings.sp_acs_url, + "spEntityId": settings.sp_entity_id, + "attributeMapping": attr_mapping, + "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), + "defaultSSORoles": settings.default_sso_roles, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, + }, + "redirectUrl": redirect_url, + "domains": domains, + } + + @staticmethod + def _compose_configure_saml_settings_by_metadata_body( + tenant_id: str, + settings: SSOSAMLSettingsByMetadata, + redirect_url: Optional[str], + domains: Optional[List[str]], + ) -> dict: + attr_mapping = None + if settings.attribute_mapping: + attr_mapping = SSOSettingsBase._attribute_mapping_to_dict(settings.attribute_mapping) + + return { + "tenantId": tenant_id, + "settings": { + "idpMetadataUrl": settings.idp_metadata_url, + "spACSUrl": settings.sp_acs_url, + "spEntityId": settings.sp_entity_id, + "attributeMapping": attr_mapping, + "roleMappings": SSOSettingsBase._role_mapping_to_dict(settings.role_mappings), + "defaultSSORoles": settings.default_sso_roles, + "groupsPriority": settings.groups_priority, + "fgaMappings": SSOSettingsBase._fga_mappings_to_dict(settings.fga_mappings), + "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, + "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, + }, + "redirectUrl": redirect_url, + "domains": domains, + } diff --git a/descope/management/_user_base.py b/descope/management/_user_base.py index 5035e65a5..3bacdd971 100644 --- a/descope/management/_user_base.py +++ b/descope/management/_user_base.py @@ -3,6 +3,7 @@ from typing import Any, List, Optional from descope.common import DeliveryMethod, LoginOptions, get_method_string +from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.management.common import ( AssociatedTenant, Sort, @@ -319,3 +320,10 @@ def _compose_patch_batch_body( users_body.append(user_body) return {"users": users_body} + + @staticmethod + def _validate_search_pagination(limit: int, page: int) -> None: + if limit < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") + if page < 0: + raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") diff --git a/descope/management/access_key.py b/descope/management/access_key.py index ad8d848cf..7262dc01d 100644 --- a/descope/management/access_key.py +++ b/descope/management/access_key.py @@ -1,14 +1,14 @@ from typing import List, Optional from descope._http_base import HTTPBase +from descope.management._access_key_base import AccessKeyBase from descope.management.common import ( AssociatedTenant, MgmtV1, - associated_tenants_to_dict, ) -class AccessKey(HTTPBase): +class AccessKey(AccessKeyBase, HTTPBase): def create( self, name: str, @@ -226,26 +226,3 @@ def delete( body={"id": id}, ) - @staticmethod - def _compose_create_body( - name: str, - expire_time: int, - role_names: List[str], - key_tenants: List[AssociatedTenant], - user_id: Optional[str] = None, - custom_claims: Optional[dict] = None, - description: Optional[str] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - return { - "name": name, - "expireTime": expire_time, - "roleNames": role_names, - "keyTenants": associated_tenants_to_dict(key_tenants), - "userId": user_id, - "customClaims": custom_claims, - "description": description, - "permittedIps": permitted_ips, - "customAttributes": custom_attributes, - } diff --git a/descope/management/access_key_async.py b/descope/management/access_key_async.py index 371170c15..9af0fd224 100644 --- a/descope/management/access_key_async.py +++ b/descope/management/access_key_async.py @@ -3,14 +3,14 @@ from typing import List, Optional from descope._http_base import AsyncHTTPBase +from descope.management._access_key_base import AccessKeyBase from descope.management.common import ( AssociatedTenant, MgmtV1, - associated_tenants_to_dict, ) -class AccessKeyAsync(AsyncHTTPBase): +class AccessKeyAsync(AccessKeyBase, AsyncHTTPBase): """Async counterpart of AccessKey — all HTTP calls are coroutines.""" async def create( @@ -230,26 +230,3 @@ async def delete( body={"id": id}, ) - @staticmethod - def _compose_create_body( - name: str, - expire_time: int, - role_names: List[str], - key_tenants: List[AssociatedTenant], - user_id: Optional[str] = None, - custom_claims: Optional[dict] = None, - description: Optional[str] = None, - permitted_ips: Optional[List[str]] = None, - custom_attributes: Optional[dict] = None, - ) -> dict: - return { - "name": name, - "expireTime": expire_time, - "roleNames": role_names, - "keyTenants": associated_tenants_to_dict(key_tenants), - "userId": user_id, - "customClaims": custom_claims, - "description": description, - "permittedIps": permitted_ips, - "customAttributes": custom_attributes, - } diff --git a/descope/management/audit.py b/descope/management/audit.py index c6c0998fa..6c3bdafb1 100644 --- a/descope/management/audit.py +++ b/descope/management/audit.py @@ -1,13 +1,13 @@ from __future__ import annotations -from datetime import datetime from typing import Any, List, Optional from descope._http_base import HTTPBase +from descope.management._audit_base import AuditBase from descope.management.common import MgmtV1 -class Audit(HTTPBase): +class Audit(AuditBase, HTTPBase): def search( self, user_ids: Optional[List[str]] = None, @@ -132,18 +132,3 @@ def create_event( self._http.post(MgmtV1.audit_create_event, body=body) - @staticmethod - def _convert_audit_record(a: dict) -> dict: - return { - "projectId": a.get("projectId", ""), - "userId": a.get("userId", ""), - "action": a.get("action", ""), - "occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000), - "device": a.get("device", ""), - "method": a.get("method", ""), - "geo": a.get("geo", ""), - "remoteAddress": a.get("remoteAddress", ""), - "loginIds": a.get("externalIds", []), - "tenants": a.get("tenants", []), - "data": a.get("data", {}), - } diff --git a/descope/management/audit_async.py b/descope/management/audit_async.py index 7e1856a23..77252d757 100644 --- a/descope/management/audit_async.py +++ b/descope/management/audit_async.py @@ -1,13 +1,13 @@ from __future__ import annotations -from datetime import datetime from typing import Any, List, Optional from descope._http_base import AsyncHTTPBase +from descope.management._audit_base import AuditBase from descope.management.common import MgmtV1 -class AuditAsync(AsyncHTTPBase): +class AuditAsync(AuditBase, AsyncHTTPBase): """Async counterpart of Audit — all HTTP calls are coroutines.""" async def search( @@ -134,18 +134,3 @@ async def create_event( await self._http.post(MgmtV1.audit_create_event, body=body) - @staticmethod - def _convert_audit_record(a: dict) -> dict: - return { - "projectId": a.get("projectId", ""), - "userId": a.get("userId", ""), - "action": a.get("action", ""), - "occurred": datetime.utcfromtimestamp(float(a.get("occurred", "0")) / 1000), - "device": a.get("device", ""), - "method": a.get("method", ""), - "geo": a.get("geo", ""), - "remoteAddress": a.get("remoteAddress", ""), - "loginIds": a.get("externalIds", []), - "tenants": a.get("tenants", []), - "data": a.get("data", {}), - } diff --git a/descope/management/jwt.py b/descope/management/jwt.py index f03a3b8a0..46ed0fb7d 100644 --- a/descope/management/jwt.py +++ b/descope/management/jwt.py @@ -2,8 +2,8 @@ from descope._http_base import HTTPBase from descope.auth import Auth -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.jwt_common import generate_jwt_response +from descope.management._jwt_base import JWTBase from descope.management.common import ( MgmtLoginOptions, MgmtSignUpOptions, @@ -13,7 +13,7 @@ ) -class JWT(HTTPBase): +class JWT(JWTBase, HTTPBase): _auth: Auth def __init__(self, http_client, auth: Auth): @@ -34,8 +34,7 @@ def update_jwt(self, jwt: str, custom_claims: dict, refresh_duration: int = 0) - Raise: AuthException: raised if update failed """ - if not jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") + self._validate_jwt(jwt) response = self._http.post( MgmtV1.update_jwt_path, body={ @@ -74,10 +73,8 @@ def impersonate( Raise: AuthException: raised if update failed """ - if not impersonator_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "impersonator_id cannot be empty") - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_impersonator_id(impersonator_id) + self._validate_login_id(login_id) response = self._http.post( MgmtV1.impersonate_path, body={ @@ -113,9 +110,7 @@ def stop_impersonation( Raise: AuthException: raised if update failed """ - if not jwt or jwt == "": - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") - + self._validate_jwt(jwt) response = self._http.post( MgmtV1.stop_impersonation_path, body={ @@ -137,14 +132,13 @@ def sign_in(self, login_id: str, login_options: Optional[MgmtLoginOptions] = Non login_options (MgmtLoginOptions): options for the login request. """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) if login_options is None: login_options = MgmtLoginOptions() - if is_jwt_required(login_options) and not login_options.jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") + if is_jwt_required(login_options): + self._validate_jwt_required(login_options) response = self._http.post( MgmtV1.mgmt_sign_in_path, @@ -206,23 +200,14 @@ def _sign_up_internal( if user is None: user = MgmtUserRequest() - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) if signup_options is None: signup_options = MgmtSignUpOptions() response = self._http.post( endpoint, - body={ - "loginId": login_id, - "user": user.to_dict(), - "emailVerified": user.email_verified, - "phoneVerified": user.phone_verified, - "ssoAppId": user.sso_app_id, - "customClaims": signup_options.custom_claims, - "refreshDuration": signup_options.refresh_duration, - }, + body=self._compose_sign_up_body(login_id, user, signup_options), params=None, ) resp = response.json() diff --git a/descope/management/jwt_async.py b/descope/management/jwt_async.py index 2f3fafe57..168f0b223 100644 --- a/descope/management/jwt_async.py +++ b/descope/management/jwt_async.py @@ -4,8 +4,8 @@ from descope._http_base import AsyncHTTPBase from descope.auth import Auth -from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.jwt_common import generate_jwt_response +from descope.management._jwt_base import JWTBase from descope.management.common import ( MgmtLoginOptions, MgmtSignUpOptions, @@ -15,7 +15,7 @@ ) -class JWTAsync(AsyncHTTPBase): +class JWTAsync(JWTBase, AsyncHTTPBase): """Async counterpart of JWT — all HTTP calls are coroutines.""" _auth: Auth @@ -38,8 +38,7 @@ async def update_jwt(self, jwt: str, custom_claims: dict, refresh_duration: int Raise: AuthException: raised if update failed """ - if not jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") + self._validate_jwt(jwt) response = await self._http.post( MgmtV1.update_jwt_path, body={ @@ -78,10 +77,8 @@ async def impersonate( Raise: AuthException: raised if update failed """ - if not impersonator_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "impersonator_id cannot be empty") - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_impersonator_id(impersonator_id) + self._validate_login_id(login_id) response = await self._http.post( MgmtV1.impersonate_path, body={ @@ -117,9 +114,7 @@ async def stop_impersonation( Raise: AuthException: raised if update failed """ - if not jwt or jwt == "": - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "jwt cannot be empty") - + self._validate_jwt(jwt) response = await self._http.post( MgmtV1.stop_impersonation_path, body={ @@ -141,14 +136,13 @@ async def sign_in(self, login_id: str, login_options: Optional[MgmtLoginOptions] login_options (MgmtLoginOptions): options for the login request. """ - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) if login_options is None: login_options = MgmtLoginOptions() - if is_jwt_required(login_options) and not login_options.jwt: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "JWT is required") + if is_jwt_required(login_options): + self._validate_jwt_required(login_options) response = await self._http.post( MgmtV1.mgmt_sign_in_path, @@ -210,23 +204,14 @@ async def _sign_up_internal( if user is None: user = MgmtUserRequest() - if not login_id: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "login_id cannot be empty") + self._validate_login_id(login_id) if signup_options is None: signup_options = MgmtSignUpOptions() response = await self._http.post( endpoint, - body={ - "loginId": login_id, - "user": user.to_dict(), - "emailVerified": user.email_verified, - "phoneVerified": user.phone_verified, - "ssoAppId": user.sso_app_id, - "customClaims": signup_options.custom_claims, - "refreshDuration": signup_options.refresh_duration, - }, + body=self._compose_sign_up_body(login_id, user, signup_options), params=None, ) resp = response.json() diff --git a/descope/management/sso_application.py b/descope/management/sso_application.py index 23e84e052..d647999cb 100644 --- a/descope/management/sso_application.py +++ b/descope/management/sso_application.py @@ -1,18 +1,17 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import List, Optional from descope._http_base import HTTPBase +from descope.management._sso_application_base import SSOApplicationBase from descope.management.common import ( MgmtV1, SAMLIDPAttributeMappingInfo, SAMLIDPGroupsMappingInfo, - saml_idp_attribute_mapping_info_to_dict, - saml_idp_groups_mapping_info_to_dict, ) -class SSOApplication(HTTPBase): +class SSOApplication(SSOApplicationBase, HTTPBase): def create_oidc_application( self, name: str, @@ -350,71 +349,3 @@ def load_all( response = self._http.get(MgmtV1.sso_application_load_all_path) return response.json() - @staticmethod - def _compose_create_update_oidc_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "description": description, - "logo": logo, - "enabled": enabled, - "loginPageUrl": login_page_url, - "forceAuthentication": force_authentication, - } - return body - - @staticmethod - def _compose_create_update_saml_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - enabled: Optional[bool] = True, - logo: Optional[str] = None, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "enabled": enabled, - "logo": logo, - "loginPageUrl": login_page_url, - "useMetadataInfo": use_metadata_info, - "metadataUrl": metadata_url, - "entityId": entity_id, - "acsUrl": acs_url, - "certificate": certificate, - "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), - "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), - "acsAllowedCallbacks": acs_allowed_callbacks, - "subjectNameIdType": subject_name_id_type, - "subjectNameIdFormat": subject_name_id_format, - "defaultRelayState": default_relay_state, - "forceAuthentication": force_authentication, - "logoutRedirectUrl": logout_redirect_url, - "defaultSignatureAlgorithm": default_signature_algorithm, - } - - return body diff --git a/descope/management/sso_application_async.py b/descope/management/sso_application_async.py index 9918e9bac..876eb14f4 100644 --- a/descope/management/sso_application_async.py +++ b/descope/management/sso_application_async.py @@ -1,18 +1,17 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import List, Optional from descope._http_base import AsyncHTTPBase +from descope.management._sso_application_base import SSOApplicationBase from descope.management.common import ( MgmtV1, SAMLIDPAttributeMappingInfo, SAMLIDPGroupsMappingInfo, - saml_idp_attribute_mapping_info_to_dict, - saml_idp_groups_mapping_info_to_dict, ) -class SSOApplicationAsync(AsyncHTTPBase): +class SSOApplicationAsync(SSOApplicationBase, AsyncHTTPBase): """Async counterpart of SSOApplication — all HTTP calls are coroutines.""" async def create_oidc_application( @@ -352,71 +351,3 @@ async def load_all( response = await self._http.get(MgmtV1.sso_application_load_all_path) return response.json() - @staticmethod - def _compose_create_update_oidc_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - enabled: Optional[bool] = True, - force_authentication: Optional[bool] = False, - ) -> dict: - body: dict[str, Any] = { - "name": name, - "id": id, - "description": description, - "logo": logo, - "enabled": enabled, - "loginPageUrl": login_page_url, - "forceAuthentication": force_authentication, - } - return body - - @staticmethod - def _compose_create_update_saml_body( - name: str, - login_page_url: str, - id: Optional[str] = None, - description: Optional[str] = None, - enabled: Optional[bool] = True, - logo: Optional[str] = None, - use_metadata_info: Optional[bool] = False, - metadata_url: Optional[str] = None, - entity_id: Optional[str] = None, - acs_url: Optional[str] = None, - certificate: Optional[str] = None, - attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, - groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, - acs_allowed_callbacks: Optional[List[str]] = None, - subject_name_id_type: Optional[str] = None, - subject_name_id_format: Optional[str] = None, - default_relay_state: Optional[str] = None, - force_authentication: Optional[bool] = False, - logout_redirect_url: Optional[str] = None, - default_signature_algorithm: Optional[str] = None, - ) -> dict: - body: dict[str, Any] = { - "id": id, - "name": name, - "description": description, - "enabled": enabled, - "logo": logo, - "loginPageUrl": login_page_url, - "useMetadataInfo": use_metadata_info, - "metadataUrl": metadata_url, - "entityId": entity_id, - "acsUrl": acs_url, - "certificate": certificate, - "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), - "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), - "acsAllowedCallbacks": acs_allowed_callbacks, - "subjectNameIdType": subject_name_id_type, - "subjectNameIdFormat": subject_name_id_format, - "defaultRelayState": default_relay_state, - "forceAuthentication": force_authentication, - "logoutRedirectUrl": logout_redirect_url, - "defaultSignatureAlgorithm": default_signature_algorithm, - } - - return body diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index de0e3c31e..b95e6da7b 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -1,6 +1,7 @@ from typing import Dict, List, Optional from descope._http_base import HTTPBase +from descope.management._sso_settings_base import SSOSettingsBase from descope.management.common import MgmtV1 @@ -198,7 +199,7 @@ def __init__( self.config_fga_tenant_id_resource_suffix = config_fga_tenant_id_resource_suffix -class SSOSettings(HTTPBase): +class SSOSettings(SSOSettingsBase, HTTPBase): def load_settings( self, tenant_id: str, @@ -451,206 +452,3 @@ def mapping( body=SSOSettings._compose_mapping_body(tenant_id, role_mappings, attribute_mapping), ) - @staticmethod - def _compose_configure_body( - tenant_id: str, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, - domains: Optional[List[str]], - ) -> dict: - return { - "tenantId": tenant_id, - "idpURL": idp_url, - "entityId": entity_id, - "idpCert": idp_cert, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_metadata_body( - tenant_id: str, - idp_metadata_url: str, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ) -> dict: - return { - "tenantId": tenant_id, - "idpMetadataURL": idp_metadata_url, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_mapping_body( - tenant_id: str, - role_mapping: Optional[List[RoleMapping]], - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - return { - "tenantId": tenant_id, - "roleMappings": SSOSettings._role_mapping_to_dict(role_mapping), - "attributeMapping": SSOSettings._attribute_mapping_to_dict(attribute_mapping), - } - - @staticmethod - def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: - if role_mapping is None: - role_mapping = [] - role_mapping_list = [] - for mapping in role_mapping: - role_mapping_list.append( - { - "groups": mapping.groups, - "roleName": mapping.role_name, - } - ) - return role_mapping_list - - @staticmethod - def _attribute_mapping_to_dict( - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - if attribute_mapping is None: - raise ValueError("Attribute mapping cannot be None") - return { - "name": attribute_mapping.name, - "email": attribute_mapping.email, - "phoneNumber": attribute_mapping.phone_number, - "group": attribute_mapping.group, - "givenName": attribute_mapping.given_name, - "middleName": attribute_mapping.middle_name, - "familyName": attribute_mapping.family_name, - "picture": attribute_mapping.picture, - "customAttributes": attribute_mapping.custom_attributes, - } - - @staticmethod - def _fga_mappings_to_dict( - fga_mappings: Optional[Dict[str, FGAGroupMapping]], - ) -> Optional[dict]: - if fga_mappings is None: - return None - result: dict = {} - for group_name, mapping in fga_mappings.items(): - relations = [] - if mapping is not None and mapping.relations: - for relation in mapping.relations: - relations.append( - { - "resource": relation.resource, - "relationDefinition": relation.relation_definition, - "namespace": relation.namespace, - } - ) - result[group_name] = {"relations": relations} - return result - - @staticmethod - def _compose_configure_oidc_settings_body( - tenant_id: str, - settings: SSOOIDCSettings, - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = { - "loginId": settings.attribute_mapping.login_id, - "name": settings.attribute_mapping.name, - "givenName": settings.attribute_mapping.given_name, - "middleName": settings.attribute_mapping.middle_name, - "familyName": settings.attribute_mapping.family_name, - "email": settings.attribute_mapping.email, - "verifiedEmail": settings.attribute_mapping.verified_email, - "username": settings.attribute_mapping.username, - "phoneNumber": settings.attribute_mapping.phone_number, - "verifiedPhone": settings.attribute_mapping.verified_phone, - "picture": settings.attribute_mapping.picture, - } - - return { - "tenantId": tenant_id, - "settings": { - "name": settings.name, - "clientId": settings.client_id, - "clientSecret": settings.client_secret, - "redirectUrl": settings.redirect_url, - "authUrl": settings.auth_url, - "tokenUrl": settings.token_url, - "userDataUrl": settings.user_data_url, - "scope": settings.scope, - "JWKsUrl": settings.jwks_url, - "userAttrMapping": attr_mapping, - "manageProviderTokens": settings.manage_provider_tokens, - "callbackDomain": settings.callback_domain, - "prompt": settings.prompt, - "grantType": settings.grant_type, - "issuer": settings.issuer, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), - }, - "domains": domains, - } - - @staticmethod - def _compose_configure_saml_settings_body( - tenant_id: str, - settings: SSOSAMLSettings, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettings._attribute_mapping_to_dict(settings.attribute_mapping) - - return { - "tenantId": tenant_id, - "settings": { - "idpUrl": settings.idp_url, - "entityId": settings.idp_entity_id, - "idpCert": settings.idp_cert, - "idpAdditionalCerts": settings.idp_additional_certs, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettings._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - }, - "redirectUrl": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_configure_saml_settings_by_metadata_body( - tenant_id: str, - settings: SSOSAMLSettingsByMetadata, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettings._attribute_mapping_to_dict(settings.attribute_mapping) - - return { - "tenantId": tenant_id, - "settings": { - "idpMetadataUrl": settings.idp_metadata_url, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettings._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettings._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - }, - "redirectUrl": redirect_url, - "domains": domains, - } diff --git a/descope/management/sso_settings_async.py b/descope/management/sso_settings_async.py index 52b04d4ba..d8f3060a1 100644 --- a/descope/management/sso_settings_async.py +++ b/descope/management/sso_settings_async.py @@ -1,13 +1,12 @@ from __future__ import annotations -from typing import Dict, List, Optional +from typing import List, Optional from descope._http_base import AsyncHTTPBase +from descope.management._sso_settings_base import SSOSettingsBase from descope.management.common import MgmtV1 from descope.management.sso_settings import ( AttributeMapping, - FGAGroupMapping, - OIDCAttributeMapping, RoleMapping, SSOOIDCSettings, SSOSAMLSettings, @@ -15,7 +14,7 @@ ) -class SSOSettingsAsync(AsyncHTTPBase): +class SSOSettingsAsync(SSOSettingsBase, AsyncHTTPBase): """Async counterpart of SSOSettings — all HTTP calls are coroutines.""" async def load_settings( @@ -270,206 +269,3 @@ async def mapping( body=SSOSettingsAsync._compose_mapping_body(tenant_id, role_mappings, attribute_mapping), ) - @staticmethod - def _compose_configure_body( - tenant_id: str, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, - domains: Optional[List[str]], - ) -> dict: - return { - "tenantId": tenant_id, - "idpURL": idp_url, - "entityId": entity_id, - "idpCert": idp_cert, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_metadata_body( - tenant_id: str, - idp_metadata_url: str, - redirect_url: Optional[str] = None, - domains: Optional[List[str]] = None, - ) -> dict: - return { - "tenantId": tenant_id, - "idpMetadataURL": idp_metadata_url, - "redirectURL": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_mapping_body( - tenant_id: str, - role_mapping: Optional[List[RoleMapping]], - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - return { - "tenantId": tenant_id, - "roleMappings": SSOSettingsAsync._role_mapping_to_dict(role_mapping), - "attributeMapping": SSOSettingsAsync._attribute_mapping_to_dict(attribute_mapping), - } - - @staticmethod - def _role_mapping_to_dict(role_mapping: Optional[List[RoleMapping]]) -> list: - if role_mapping is None: - role_mapping = [] - role_mapping_list = [] - for mapping in role_mapping: - role_mapping_list.append( - { - "groups": mapping.groups, - "roleName": mapping.role_name, - } - ) - return role_mapping_list - - @staticmethod - def _attribute_mapping_to_dict( - attribute_mapping: Optional[AttributeMapping], - ) -> dict: - if attribute_mapping is None: - raise ValueError("Attribute mapping cannot be None") - return { - "name": attribute_mapping.name, - "email": attribute_mapping.email, - "phoneNumber": attribute_mapping.phone_number, - "group": attribute_mapping.group, - "givenName": attribute_mapping.given_name, - "middleName": attribute_mapping.middle_name, - "familyName": attribute_mapping.family_name, - "picture": attribute_mapping.picture, - "customAttributes": attribute_mapping.custom_attributes, - } - - @staticmethod - def _fga_mappings_to_dict( - fga_mappings: Optional[Dict[str, FGAGroupMapping]], - ) -> Optional[dict]: - if fga_mappings is None: - return None - result: dict = {} - for group_name, mapping in fga_mappings.items(): - relations = [] - if mapping is not None and mapping.relations: - for relation in mapping.relations: - relations.append( - { - "resource": relation.resource, - "relationDefinition": relation.relation_definition, - "namespace": relation.namespace, - } - ) - result[group_name] = {"relations": relations} - return result - - @staticmethod - def _compose_configure_oidc_settings_body( - tenant_id: str, - settings: SSOOIDCSettings, - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = { - "loginId": settings.attribute_mapping.login_id, - "name": settings.attribute_mapping.name, - "givenName": settings.attribute_mapping.given_name, - "middleName": settings.attribute_mapping.middle_name, - "familyName": settings.attribute_mapping.family_name, - "email": settings.attribute_mapping.email, - "verifiedEmail": settings.attribute_mapping.verified_email, - "username": settings.attribute_mapping.username, - "phoneNumber": settings.attribute_mapping.phone_number, - "verifiedPhone": settings.attribute_mapping.verified_phone, - "picture": settings.attribute_mapping.picture, - } - - return { - "tenantId": tenant_id, - "settings": { - "name": settings.name, - "clientId": settings.client_id, - "clientSecret": settings.client_secret, - "redirectUrl": settings.redirect_url, - "authUrl": settings.auth_url, - "tokenUrl": settings.token_url, - "userDataUrl": settings.user_data_url, - "scope": settings.scope, - "JWKsUrl": settings.jwks_url, - "userAttrMapping": attr_mapping, - "manageProviderTokens": settings.manage_provider_tokens, - "callbackDomain": settings.callback_domain, - "prompt": settings.prompt, - "grantType": settings.grant_type, - "issuer": settings.issuer, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), - }, - "domains": domains, - } - - @staticmethod - def _compose_configure_saml_settings_body( - tenant_id: str, - settings: SSOSAMLSettings, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettingsAsync._attribute_mapping_to_dict(settings.attribute_mapping) - - return { - "tenantId": tenant_id, - "settings": { - "idpUrl": settings.idp_url, - "entityId": settings.idp_entity_id, - "idpCert": settings.idp_cert, - "idpAdditionalCerts": settings.idp_additional_certs, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettingsAsync._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - }, - "redirectUrl": redirect_url, - "domains": domains, - } - - @staticmethod - def _compose_configure_saml_settings_by_metadata_body( - tenant_id: str, - settings: SSOSAMLSettingsByMetadata, - redirect_url: Optional[str], - domains: Optional[List[str]], - ) -> dict: - attr_mapping = None - if settings.attribute_mapping: - attr_mapping = SSOSettingsAsync._attribute_mapping_to_dict(settings.attribute_mapping) - - return { - "tenantId": tenant_id, - "settings": { - "idpMetadataUrl": settings.idp_metadata_url, - "spACSUrl": settings.sp_acs_url, - "spEntityId": settings.sp_entity_id, - "attributeMapping": attr_mapping, - "roleMappings": SSOSettingsAsync._role_mapping_to_dict(settings.role_mappings), - "defaultSSORoles": settings.default_sso_roles, - "groupsPriority": settings.groups_priority, - "fgaMappings": SSOSettingsAsync._fga_mappings_to_dict(settings.fga_mappings), - "configFGATenantIDResourcePrefix": settings.config_fga_tenant_id_resource_prefix, - "configFGATenantIDResourceSuffix": settings.config_fga_tenant_id_resource_suffix, - }, - "redirectUrl": redirect_url, - "domains": domains, - } diff --git a/descope/management/user.py b/descope/management/user.py index 769a07659..9ad412265 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -697,11 +697,7 @@ def search_all( tenant_ids = [] if tenant_ids is None else tenant_ids role_names = [] if role_names is None else role_names - if limit < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") - - if page < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + self._validate_search_pagination(limit, page) body = { "tenantIds": tenant_ids, "roleNames": role_names, @@ -814,11 +810,7 @@ def search_all_test_users( tenant_ids = [] if tenant_ids is None else tenant_ids role_names = [] if role_names is None else role_names - if limit < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") - - if page < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + self._validate_search_pagination(limit, page) body = { "tenantIds": tenant_ids, "roleNames": role_names, diff --git a/descope/management/user_async.py b/descope/management/user_async.py index c611d6b31..5ed85cbaa 100644 --- a/descope/management/user_async.py +++ b/descope/management/user_async.py @@ -702,11 +702,7 @@ async def search_all( tenant_ids = [] if tenant_ids is None else tenant_ids role_names = [] if role_names is None else role_names - if limit < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") - - if page < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + self._validate_search_pagination(limit, page) body = { "tenantIds": tenant_ids, "roleNames": role_names, @@ -819,11 +815,7 @@ async def search_all_test_users( tenant_ids = [] if tenant_ids is None else tenant_ids role_names = [] if role_names is None else role_names - if limit < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "limit must be non-negative") - - if page < 0: - raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "page must be non-negative") + self._validate_search_pagination(limit, page) body = { "tenantIds": tenant_ids, "roleNames": role_names, From c4d0555ddcade300166082f49765402d07b69250 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:56:27 +0300 Subject: [PATCH 15/17] fix: ruff lint errors and complete validation/compose dedup refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing `from datetime import datetime` to audit.py / audit_async.py - Rename camelCase params in tenant_async.py (JITDisabled→jit_disabled) and user_async.py (withRefreshToken/forceRefresh→snake_case); `from __future__ import annotations` caused ruff N803 to fire only on the async files - Fix N806 variable names in _user_base.py (usersBody→users_body, uBody→u_body) - Wire JWTBase into jwt.py / jwt_async.py; UserBase._validate_search_pagination into user.py / user_async.py; complete sso_settings_async.py dedup - Remove unused imports (Any from fga_async, url_params_to_dict from outbound_application) --- descope/management/_jwt_base.py | 4 +--- descope/management/_user_base.py | 13 +++++-------- descope/management/audit.py | 1 + descope/management/audit_async.py | 1 + descope/management/fga_async.py | 2 +- descope/management/outbound_application.py | 1 - descope/management/tenant_async.py | 6 +++--- descope/management/user_async.py | 15 +++++++-------- tests/management/test_access_key.py | 3 +-- tests/management/test_audit.py | 3 +-- tests/management/test_authz.py | 3 +-- tests/management/test_descoper.py | 3 +-- tests/management/test_fga.py | 3 +-- tests/management/test_flow.py | 3 +-- tests/management/test_group.py | 3 +-- tests/management/test_jwt.py | 3 +-- tests/management/test_license.py | 3 +-- tests/management/test_mgmtkey.py | 4 +--- tests/management/test_outbound_application.py | 3 +-- tests/management/test_permission.py | 3 +-- tests/management/test_project.py | 3 +-- tests/management/test_role.py | 3 +-- tests/management/test_sso_settings.py | 3 +-- tests/management/test_tenant.py | 3 +-- tests/management/test_user.py | 3 +-- 25 files changed, 36 insertions(+), 59 deletions(-) diff --git a/descope/management/_jwt_base.py b/descope/management/_jwt_base.py index 3334a1ea5..3e6f4efac 100644 --- a/descope/management/_jwt_base.py +++ b/descope/management/_jwt_base.py @@ -1,10 +1,8 @@ # This is not part of the public API but a code helper from __future__ import annotations -from typing import Optional - from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException -from descope.management.common import MgmtUserRequest, MgmtSignUpOptions +from descope.management.common import MgmtSignUpOptions, MgmtUserRequest class JWTBase: diff --git a/descope/management/_user_base.py b/descope/management/_user_base.py index 3bacdd971..eda89260e 100644 --- a/descope/management/_user_base.py +++ b/descope/management/_user_base.py @@ -2,13 +2,10 @@ from typing import Any, List, Optional -from descope.common import DeliveryMethod, LoginOptions, get_method_string from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.management.common import ( AssociatedTenant, - Sort, associated_tenants_to_dict, - sort_to_dict, ) from descope.management.user_pwd import UserPassword @@ -140,7 +137,7 @@ def _compose_create_batch_body( send_sms: Optional[bool], locale: Optional[str] = None, ) -> dict: - usersBody = [] + users_body = [] for user in users: role_names = [] if user.role_names is None else user.role_names user_tenants = [] if user.user_tenants is None else user.user_tenants @@ -149,7 +146,7 @@ def _compose_create_batch_body( hashed_password = None if (user.password is not None) and (user.password.hashed is not None): hashed_password = user.password.hashed.to_dict() - uBody = UserBase._compose_update_body( + u_body = UserBase._compose_update_body( login_id=user.login_id, email=user.email, phone=user.phone, @@ -171,10 +168,10 @@ def _compose_create_batch_body( seed=user.seed, ) if user.status is not None: - uBody["status"] = user.status - usersBody.append(uBody) + u_body["status"] = user.status + users_body.append(u_body) - body = {"users": usersBody, "invite": True} + body = {"users": users_body, "invite": True} if invite_url is not None: body["inviteUrl"] = invite_url if send_mail is not None: diff --git a/descope/management/audit.py b/descope/management/audit.py index 6c3bdafb1..ac3215c9d 100644 --- a/descope/management/audit.py +++ b/descope/management/audit.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Any, List, Optional from descope._http_base import HTTPBase diff --git a/descope/management/audit_async.py b/descope/management/audit_async.py index 77252d757..1a6f5a7e9 100644 --- a/descope/management/audit_async.py +++ b/descope/management/audit_async.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import Any, List, Optional from descope._http_base import AsyncHTTPBase diff --git a/descope/management/fga_async.py b/descope/management/fga_async.py index 5ab3b4ec6..bfaa12272 100644 --- a/descope/management/fga_async.py +++ b/descope/management/fga_async.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import List, Optional from descope._http_base import AsyncHTTPBase from descope.management.common import MgmtV1 diff --git a/descope/management/outbound_application.py b/descope/management/outbound_application.py index cd51dca60..41729d562 100644 --- a/descope/management/outbound_application.py +++ b/descope/management/outbound_application.py @@ -11,7 +11,6 @@ MgmtV1, PromptType, URLParam, - url_params_to_dict, ) diff --git a/descope/management/tenant_async.py b/descope/management/tenant_async.py index edd0193fa..1ae001d1e 100644 --- a/descope/management/tenant_async.py +++ b/descope/management/tenant_async.py @@ -126,7 +126,7 @@ async def update_settings( enable_inactivity: Optional[bool] = None, inactivity_time: Optional[int] = None, inactivity_time_unit: Optional[SessionExpirationUnit] = None, - JITDisabled: Optional[bool] = None, + jit_disabled: Optional[bool] = None, sso_setup_suite_settings: Optional[SSOSetupSuiteSettings] = None, enforce_sso: Optional[bool] = None, enforce_sso_exclusions: Optional[List[str]] = None, @@ -150,7 +150,7 @@ async def update_settings( enable_inactivity (Optional[bool]): Whether inactivity timeout is enabled. inactivity_time (Optional[int]): Inactivity timeout duration. inactivity_time_unit (Optional[SessionExpirationUnit]): Unit for inactivity timeout. - JITDisabled (Optional[bool]): Whether JIT is disabled. + jit_disabled (Optional[bool]): Whether JIT is disabled. sso_setup_suite_settings (Optional[SSOSetupSuiteSettings]): SSO Setup Suite configuration. enforce_sso (Optional[bool]): Whether to enforce SSO for the tenant. enforce_sso_exclusions (Optional[List[str]]): List of user IDs excluded from SSO enforcement. @@ -174,7 +174,7 @@ async def update_settings( "enableInactivity": enable_inactivity, "inactivityTime": inactivity_time, "inactivityTimeUnit": inactivity_time_unit, - "JITDisabled": JITDisabled, + "JITDisabled": jit_disabled, "ssoSetupSuiteSettings": (sso_setup_suite_settings.to_dict() if sso_setup_suite_settings else None), "enforceSSO": enforce_sso, "enforceSSOExclusions": enforce_sso_exclusions, diff --git a/descope/management/user_async.py b/descope/management/user_async.py index 5ed85cbaa..227d60a3d 100644 --- a/descope/management/user_async.py +++ b/descope/management/user_async.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, List, Optional, Union +from typing import List, Optional, Union from descope._http_base import AsyncHTTPBase from descope.common import DeliveryMethod, LoginOptions, get_method_string @@ -12,7 +12,6 @@ Sort, sort_to_dict, ) -from descope.management.user_pwd import UserPassword class UserAsync(UserBase, AsyncHTTPBase): @@ -872,8 +871,8 @@ async def get_provider_token( self, login_id: str, provider: str, - withRefreshToken: Optional[bool] = False, - forceRefresh: Optional[bool] = False, + with_refresh_token: Optional[bool] = False, + force_refresh: Optional[bool] = False, ) -> dict: """ Get the provider token for the given login ID. @@ -883,8 +882,8 @@ async def get_provider_token( Args: login_id (str): The login ID of the user. provider (str): The provider name (google, facebook, etc'). - withRefreshToken (bool): Optional, set to true to get also the refresh token. - forceRefresh (bool): Optional, set to true to force refresh the token. + with_refresh_token (bool): Optional, set to true to get also the refresh token. + force_refresh (bool): Optional, set to true to force refresh the token. Return value (dict): Return dict in the format @@ -899,8 +898,8 @@ async def get_provider_token( params={ "loginId": login_id, "provider": provider, - "withRefreshToken": withRefreshToken, - "forceRefresh": forceRefresh, + "withRefreshToken": with_refresh_token, + "forceRefresh": force_refresh, }, ) return response.json() diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py index d7c8408b9..26badba0a 100644 --- a/tests/management/test_access_key.py +++ b/tests/management/test_access_key.py @@ -2,9 +2,8 @@ from descope import AssociatedTenant, AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_audit.py b/tests/management/test_audit.py index 9449f3ede..772f076ea 100644 --- a/tests/management/test_audit.py +++ b/tests/management/test_audit.py @@ -4,9 +4,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_authz.py b/tests/management/test_authz.py index f47486374..6f6099b20 100644 --- a/tests/management/test_authz.py +++ b/tests/management/test_authz.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT AUTH_HEADERS = { diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py index bad741d35..9c26a336f 100644 --- a/tests/management/test_descoper.py +++ b/tests/management/test_descoper.py @@ -10,9 +10,8 @@ DescoperTagRole, ) from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py index 7763d31db..53d7659df 100644 --- a/tests/management/test_fga.py +++ b/tests/management/test_fga.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT MGMT_HEADERS = { diff --git a/tests/management/test_flow.py b/tests/management/test_flow.py index 89f3831d3..3397dbbb7 100644 --- a/tests/management/test_flow.py +++ b/tests/management/test_flow.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import FlowRunOptions, MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_group.py b/tests/management/test_group.py index 21bfdf8de..a6840258f 100644 --- a/tests/management/test_group.py +++ b/tests/management/test_group.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index e1f138fc6..8bcdba95c 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtLoginOptions, MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_license.py b/tests/management/test_license.py index 9d0d49e2a..ef8ea2273 100644 --- a/tests/management/test_license.py +++ b/tests/management/test_license.py @@ -4,9 +4,8 @@ from descope import AuthException, DescopeClient from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_mgmtkey.py b/tests/management/test_mgmtkey.py index 0f71d1889..7008f0cbd 100644 --- a/tests/management/test_mgmtkey.py +++ b/tests/management/test_mgmtkey.py @@ -1,16 +1,14 @@ import pytest from descope import ( - AuthException, MgmtKeyProjectRole, MgmtKeyReBac, MgmtKeyStatus, MgmtKeyTagRole, ) from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_outbound_application.py b/tests/management/test_outbound_application.py index 341edd245..2520c8c3a 100644 --- a/tests/management/test_outbound_application.py +++ b/tests/management/test_outbound_application.py @@ -3,9 +3,8 @@ from descope import AuthException from descope.management.common import AccessType, MgmtV1, PromptType, URLParam from descope.management.outbound_application import OutboundApplication - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT DUMMY_TOKEN = "inbound-app-token" diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index e7a92b7c1..4f0cd0b26 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_project.py b/tests/management/test_project.py index dd1bea549..3446d0a7a 100644 --- a/tests/management/test_project.py +++ b/tests/management/test_project.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 95d078d5e..ffe51aadb 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -2,9 +2,8 @@ from descope import AuthException from descope.management.common import MgmtV1 - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 45085c488..955471e44 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -13,9 +13,8 @@ SSOSAMLSettingsByMetadata, SSOSettings, ) - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 9acf2dd5f..d6cff8956 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -6,9 +6,8 @@ SSOSetupSuiteSettings, SSOSetupSuiteSettingsDisabledFeatures, ) - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT MGMT_HEADERS = { diff --git a/tests/management/test_user.py b/tests/management/test_user.py index a28f884a0..f5191c949 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -11,9 +11,8 @@ UserPasswordFirebase, UserPasswordPbkdf2, ) - -from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response from tests.testutils import PUBLIC_KEY_DICT From f1b9f129698c82fcf5f652f80b3fed0320dea533 Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:53:06 +0300 Subject: [PATCH 16/17] fix: address async auth review comments - Remove stale pytest.skip from test_mgmt (MGMTAsync raises AuthException without key, same as sync) - Add per-method one-liner docstrings to all 8 async auth classes to match TOTPAsync pattern - Fix response param shadowing in webauthn sign_up_finish/sign_in_finish (sync + async) - Add gitleaks fingerprints for test-fixture JWTs in test_descope_client_parity.py --- .gitleaksignore | 2 ++ descope/authmethod/enchantedlink_async.py | 6 ++++++ descope/authmethod/magiclink_async.py | 6 ++++++ descope/authmethod/oauth_async.py | 2 ++ descope/authmethod/otp_async.py | 6 ++++++ descope/authmethod/password_async.py | 6 ++++++ descope/authmethod/saml_async.py | 2 ++ descope/authmethod/sso_async.py | 2 ++ descope/authmethod/webauthn.py | 12 ++++++------ descope/authmethod/webauthn_async.py | 19 +++++++++++++------ tests/test_descope_client.py | 4 +--- 11 files changed, 52 insertions(+), 15 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 0428d624a..33d7ddec3 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -85,6 +85,8 @@ e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:185 e7f5ad4253ad82236a5cff5f8c06878bfb190b00:tests/test_descope_client.py:jwt:197 ece761372c78a9ad8a57da5f6d13431d298a99db:tests/test_auth.py:jwt:562 f3ec873c83a7067a1226d8b712b756b1b599fb3b:tests/test_descope_client.py:jwt:519 +e44fd5977ffd62c56f63c32e2422bf2c0e87c816:tests/test_descope_client_parity.py:jwt:100 +e44fd5977ffd62c56f63c32e2422bf2c0e87c816:tests/test_descope_client_parity.py:jwt:761 b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1349 b6a2e217be5dceb6c85332d2e193619894d3a36e:README.md:generic-api-key:1372 c751b1e9df5dcb6e6b3a62601174065ebf03144f:README.md:generic-api-key:1358 diff --git a/descope/authmethod/enchantedlink_async.py b/descope/authmethod/enchantedlink_async.py index bd43e9245..4ab7a7527 100644 --- a/descope/authmethod/enchantedlink_async.py +++ b/descope/authmethod/enchantedlink_async.py @@ -24,6 +24,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> dict: + """Send an enchanted-link email for sign-in; returns the pending-ref and link-id.""" self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -40,6 +41,7 @@ async def sign_up( user: dict | None, signup_options: SignUpOptions | None = None, ) -> dict: + """Send an enchanted-link email for sign-up; returns the pending-ref and link-id.""" if not user: user = {} @@ -56,6 +58,7 @@ async def sign_up( return response.json() async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOptions | None = None) -> dict: + """Send an enchanted-link for sign-up or sign-in depending on whether the user exists.""" login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( @@ -70,6 +73,7 @@ async def sign_up_or_in(self, login_id: str, uri: str, signup_options: SignUpOpt return response.json() async def get_session(self, pending_ref: str) -> dict: + """Poll for session JWTs once the user has clicked the enchanted link.""" uri = EndpointsV1.get_session_enchantedlink_auth_path body = self._compose_get_session_body(pending_ref) response = await self._http.post(uri, body=body) @@ -77,6 +81,7 @@ async def get_session(self, pending_ref: str) -> dict: return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), None) async def verify(self, token: str) -> None: + """Mark the enchanted-link token as clicked (called by the link-handler endpoint).""" uri = EndpointsV1.verify_enchantedlink_auth_path body = self._compose_verify_body(token) await self._http.post(uri, body=body) @@ -92,6 +97,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> dict: + """Send an enchanted-link to a new email address to verify the update.""" self._validate_login_id(login_id) Auth.validate_email(email) diff --git a/descope/authmethod/magiclink_async.py b/descope/authmethod/magiclink_async.py index 67984f032..d04449a22 100644 --- a/descope/authmethod/magiclink_async.py +++ b/descope/authmethod/magiclink_async.py @@ -27,6 +27,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> str: + """Send a magic link for sign-in; returns the masked delivery address.""" self._validate_sign_in_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -44,6 +45,7 @@ async def sign_up( user: dict | None = None, signup_options: SignUpOptions | None = None, ) -> str: + """Send a magic link for sign-up to a new user; returns the masked delivery address.""" if not user: user = {} @@ -66,6 +68,7 @@ async def sign_up_or_in( uri: str, signup_options: SignUpOptions | None = None, ) -> str: + """Send a magic link for sign-up or sign-in depending on whether the user exists.""" login_options: LoginOptions | None = None if signup_options is not None: login_options = LoginOptions( @@ -79,6 +82,7 @@ async def sign_up_or_in( return Auth.extract_masked_address(response.json(), method) async def verify(self, token: str, audience: str | None | Iterable[str] = None) -> dict: + """Verify a magic link token and return session JWTs.""" url = EndpointsV1.verify_magiclink_auth_path body = self._compose_verify_body(token) response = await self._http.post(url, body=body) @@ -96,6 +100,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> str: + """Send a magic link to a new email to verify the update; returns the masked address.""" self._validate_login_id(login_id) Auth.validate_email(email) @@ -125,6 +130,7 @@ async def update_user_phone( template_id: str | None = None, provider_id: str | None = None, ) -> str: + """Send a magic link to a new phone number to verify the update; returns the masked address.""" self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/oauth_async.py b/descope/authmethod/oauth_async.py index 44592b764..62a4c7f69 100644 --- a/descope/authmethod/oauth_async.py +++ b/descope/authmethod/oauth_async.py @@ -23,6 +23,7 @@ async def start( login_options: Optional[LoginOptions] = None, refresh_token: Optional[str] = None, ) -> dict: + """Start an OAuth flow; returns the redirect URL to send the user to.""" if not self._verify_provider(provider): raise AuthException( 400, @@ -43,6 +44,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: + """Exchange an OAuth code for session JWTs.""" self._validate_exchange_code(code) uri = EndpointsV1.oauth_exchange_token_path body = self._compose_exchange_body(code) diff --git a/descope/authmethod/otp_async.py b/descope/authmethod/otp_async.py index 14a10ae78..1df8231c0 100644 --- a/descope/authmethod/otp_async.py +++ b/descope/authmethod/otp_async.py @@ -26,6 +26,7 @@ async def sign_in( login_options: LoginOptions | None = None, refresh_token: str | None = None, ) -> str: + """Send an OTP to the user's delivery address for sign-in; returns the masked address.""" self._validate_login_id(login_id) validate_refresh_token_provided(login_options, refresh_token) @@ -42,6 +43,7 @@ async def sign_up( user: dict | None = None, signup_options: SignUpOptions | None = None, ) -> str: + """Send an OTP to a new user's delivery address for sign-up; returns the masked address.""" if not user: user = {} @@ -63,6 +65,7 @@ async def sign_up_or_in( login_id: str, signup_options: SignUpOptions | None = None, ) -> str: + """Send an OTP for sign-up or sign-in depending on whether the user exists.""" self._validate_login_id(login_id) uri = self._compose_sign_up_or_in_url(method) @@ -84,6 +87,7 @@ async def verify_code( code: str, audience: str | None | Iterable[str] = None, ) -> dict: + """Verify an OTP code and return session JWTs.""" self._validate_login_id(login_id) uri = self._compose_verify_code_url(method) @@ -104,6 +108,7 @@ async def update_user_email( template_id: str | None = None, provider_id: str | None = None, ) -> str: + """Send an OTP to a new email address to verify the update; returns the masked address.""" self._validate_login_id(login_id) Auth.validate_email(email) @@ -133,6 +138,7 @@ async def update_user_phone( template_id: str | None = None, provider_id: str | None = None, ) -> str: + """Send an OTP to a new phone number to verify the update; returns the masked address.""" self._validate_login_id(login_id) Auth.validate_phone(method, phone) diff --git a/descope/authmethod/password_async.py b/descope/authmethod/password_async.py index 7f6eebfc9..afe0a0681 100644 --- a/descope/authmethod/password_async.py +++ b/descope/authmethod/password_async.py @@ -17,6 +17,7 @@ async def sign_up( user: dict | None = None, audience: str | None | Iterable[str] = None, ) -> dict: + """Create a new user with a password and return session JWTs.""" self._validate_login_id(login_id) self._validate_password(password) @@ -33,6 +34,7 @@ async def sign_in( password: str, audience: str | None | Iterable[str] = None, ) -> dict: + """Verify the user's password and return session JWTs.""" self._validate_login_id(login_id) self._validate_sign_in_password(password) @@ -48,6 +50,7 @@ async def send_reset( redirect_url: str | None = None, template_options: dict | None = None, ) -> dict: + """Send a password reset prompt to the user per the configured reset method.""" self._validate_login_id(login_id) uri = EndpointsV1.send_reset_password_path @@ -62,6 +65,7 @@ async def send_reset( return response.json() async def update(self, login_id: str, new_password: str, refresh_token: str) -> None: + """Update the password for an existing logged-in user.""" self._validate_login_id(login_id) self._validate_new_password(new_password) self._validate_refresh_token(refresh_token) @@ -80,6 +84,7 @@ async def replace( new_password: str, audience: str | None | Iterable[str] = None, ) -> dict: + """Authenticate with old_password, then replace it with new_password; returns session JWTs.""" self._validate_login_id(login_id) self._validate_old_password(old_password) self._validate_new_password(new_password) @@ -98,5 +103,6 @@ async def replace( return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) async def get_policy(self) -> dict: + """Return the project's password policy (min length, character requirements, etc.).""" response = await self._http.get(uri=EndpointsV1.password_policy_path) return response.json() diff --git a/descope/authmethod/saml_async.py b/descope/authmethod/saml_async.py index 56a8df079..c38c9c6d7 100644 --- a/descope/authmethod/saml_async.py +++ b/descope/authmethod/saml_async.py @@ -23,6 +23,7 @@ async def start( login_options: Optional[LoginOptions] = None, refresh_token: Optional[str] = None, ) -> dict: + """Start a SAML flow; returns the redirect URL to send the user to.""" self._validate_tenant(tenant) self._validate_return_url(return_url) @@ -39,6 +40,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: + """Exchange a SAML code for session JWTs.""" self._validate_exchange_code(code) uri = EndpointsV1.saml_exchange_token_path body = self._compose_exchange_body(code) diff --git a/descope/authmethod/sso_async.py b/descope/authmethod/sso_async.py index b59a38549..070ba912f 100644 --- a/descope/authmethod/sso_async.py +++ b/descope/authmethod/sso_async.py @@ -26,6 +26,7 @@ async def start( login_hint: Optional[str] = None, force_authn: Optional[bool] = None, ) -> dict: + """Start an SSO flow; returns the redirect URL to send the user to.""" self._validate_tenant(tenant) validate_refresh_token_provided(login_options, refresh_token) @@ -48,6 +49,7 @@ async def start( return response.json() async def exchange_token(self, code: str) -> dict: + """Exchange an SSO code for session JWTs.""" self._validate_exchange_code(code) uri = EndpointsV1.sso_exchange_token_path body = self._compose_exchange_body(code) diff --git a/descope/authmethod/webauthn.py b/descope/authmethod/webauthn.py index 128fb5b70..b453d21bc 100644 --- a/descope/authmethod/webauthn.py +++ b/descope/authmethod/webauthn.py @@ -46,9 +46,9 @@ def sign_up_finish( self._validate_response(response) uri = EndpointsV1.sign_up_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) - response = self._http.post(uri, body=body) - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + http_response = self._http.post(uri, body=body) + resp = http_response.json() + return self._auth.generate_jwt_response(resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) def sign_in_start( self, @@ -84,9 +84,9 @@ def sign_in_finish( uri = EndpointsV1.sign_in_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) - response = self._http.post(uri, body=body) - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + http_response = self._http.post(uri, body=body) + resp = http_response.json() + return self._auth.generate_jwt_response(resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) def sign_up_or_in_start( self, diff --git a/descope/authmethod/webauthn_async.py b/descope/authmethod/webauthn_async.py index ba9c3d00a..6f7328ce1 100644 --- a/descope/authmethod/webauthn_async.py +++ b/descope/authmethod/webauthn_async.py @@ -21,6 +21,7 @@ async def sign_up_start( origin: Optional[str], user: Optional[dict] = None, ) -> dict: + """Start a WebAuthn sign-up flow; returns the challenge options for the browser.""" self._validate_login_id(login_id) self._validate_origin(origin) @@ -38,14 +39,15 @@ async def sign_up_finish( response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: + """Complete a WebAuthn sign-up and return session JWTs.""" self._validate_transaction_id(transaction_id) self._validate_response(response) uri = EndpointsV1.sign_up_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) - response = await self._http.post(uri, body=body) - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + http_response = await self._http.post(uri, body=body) + resp = http_response.json() + return self._auth.generate_jwt_response(resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) async def sign_in_start( self, @@ -54,6 +56,7 @@ async def sign_in_start( login_options: Optional[LoginOptions] = None, refresh_token: Optional[str] = None, ) -> dict: + """Start a WebAuthn sign-in flow; returns the challenge options for the browser.""" self._validate_login_id(login_id) self._validate_origin(origin) @@ -70,20 +73,22 @@ async def sign_in_finish( response, audience: Union[str, None, Iterable[str]] = None, ) -> dict: + """Complete a WebAuthn sign-in and return session JWTs.""" self._validate_transaction_id(transaction_id) self._validate_response(response) uri = EndpointsV1.sign_in_auth_webauthn_finish_path body = self._compose_sign_up_in_finish_body(transaction_id, response) - response = await self._http.post(uri, body=body) - resp = response.json() - return self._auth.generate_jwt_response(resp, response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) + http_response = await self._http.post(uri, body=body) + resp = http_response.json() + return self._auth.generate_jwt_response(resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience) async def sign_up_or_in_start( self, login_id: str, origin: str, ) -> dict: + """Start a WebAuthn sign-up-or-in flow; returns the challenge options for the browser.""" self._validate_login_id(login_id) self._validate_origin(origin) @@ -93,6 +98,7 @@ async def sign_up_or_in_start( return response.json() async def update_start(self, login_id: str, refresh_token: str, origin: str) -> dict: + """Start adding a new WebAuthn authenticator to an existing user.""" self._validate_login_id(login_id) self._validate_refresh_token(refresh_token) @@ -102,6 +108,7 @@ async def update_start(self, login_id: str, refresh_token: str, origin: str) -> return response.json() async def update_finish(self, transaction_id: str, response: str) -> None: + """Complete adding a new WebAuthn authenticator to an existing user.""" self._validate_transaction_id(transaction_id) self._validate_response(response) diff --git a/tests/test_descope_client.py b/tests/test_descope_client.py index 18cab0eb2..e154e6b65 100644 --- a/tests/test_descope_client.py +++ b/tests/test_descope_client.py @@ -90,10 +90,8 @@ async def test_project_id_from_env_without_env(self, client_factory): client_factory.make("") async def test_mgmt(self, descope_client): - if descope_client.mode != "sync": - pytest.skip("mgmt not available on DescopeClientAsync") - # Validate that any invocation of specific mgmt object raises AuthException as mgmt key was not set + # (applies equally to sync DescopeClient and async DescopeClientAsync) with pytest.raises(AuthException): _ = descope_client.mgmt.tenant with pytest.raises(AuthException): From 52c443013addafb5da4e461f3148aad326154c9d Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:23:55 +0300 Subject: [PATCH 17/17] fix(types): accept Optional[str] in SAML and WebAuthn base method signatures Mypy flagged arg-type errors because the callers pass Optional[str] (validated by guard methods that raise on None) while the base method signatures declared str. The implementations already handle None via truthiness checks; widen the parameter types to match the calling convention. --- descope/authmethod/_saml_base.py | 6 ++++-- descope/authmethod/_webauthn_base.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/descope/authmethod/_saml_base.py b/descope/authmethod/_saml_base.py index 38b5172eb..73afb28f2 100644 --- a/descope/authmethod/_saml_base.py +++ b/descope/authmethod/_saml_base.py @@ -1,6 +1,8 @@ # This is not part of the public API but a code helper from __future__ import annotations +from typing import Optional + from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException @@ -20,7 +22,7 @@ def _validate_tenant(tenant: str) -> None: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Tenant cannot be empty") @staticmethod - def _validate_return_url(return_url: str) -> None: + def _validate_return_url(return_url: Optional[str]) -> None: if not return_url: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Return url cannot be empty") @@ -30,7 +32,7 @@ def _validate_exchange_code(code: str) -> None: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "exchange code is empty") @staticmethod - def _compose_start_params(tenant: str, return_url: str) -> dict: + def _compose_start_params(tenant: str, return_url: Optional[str]) -> dict: res: dict = {"tenant": tenant} if return_url is not None and return_url != "": res["redirectURL"] = return_url diff --git a/descope/authmethod/_webauthn_base.py b/descope/authmethod/_webauthn_base.py index 68ba44ead..f92ac11cd 100644 --- a/descope/authmethod/_webauthn_base.py +++ b/descope/authmethod/_webauthn_base.py @@ -43,7 +43,7 @@ def _validate_refresh_token(refresh_token: str) -> None: raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "Refresh token cannot be empty") @staticmethod - def _compose_sign_up_start_body(login_id: str, user: dict, origin: str) -> dict: + def _compose_sign_up_start_body(login_id: Optional[str], user: dict, origin: Optional[str]) -> dict: user.update({"loginId": login_id}) return {"user": user, "origin": origin}