From 503fc9262fd9622ed740fa553dfde87f8ca7867b Mon Sep 17 00:00:00 2001 From: Lior eliav <33252035+LioriE@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:15:42 +0300 Subject: [PATCH] e2e --- .github/workflows/ci.yml | 52 ++++- README.md | 29 +++ pyproject.toml | 7 +- tests/_unified.py | 35 ++++ tests/conftest.py | 20 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 61 ++++++ tests/e2e/management/__init__.py | 0 tests/e2e/management/test_descoper.py | 64 ++++++ tests/e2e/management/test_flow.py | 14 ++ tests/e2e/management/test_jwt.py | 27 +++ tests/e2e/management/test_mgmtkey.py | 73 +++++++ tests/e2e/management/test_project.py | 21 ++ tests/e2e/management/test_sso.py | 283 ++++++++++++++++++++++++++ tests/e2e/management/test_user.py | 194 ++++++++++++++++++ tests/e2e/test_access_keys.py | 97 +++++++++ tests/e2e/test_magiclink.py | 106 ++++++++++ tests/e2e/test_otp.py | 102 ++++++++++ tests/e2e/test_password.py | 114 +++++++++++ uv.lock | 29 +++ 20 files changed, 1309 insertions(+), 19 deletions(-) create mode 100644 tests/_unified.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/management/__init__.py create mode 100644 tests/e2e/management/test_descoper.py create mode 100644 tests/e2e/management/test_flow.py create mode 100644 tests/e2e/management/test_jwt.py create mode 100644 tests/e2e/management/test_mgmtkey.py create mode 100644 tests/e2e/management/test_project.py create mode 100644 tests/e2e/management/test_sso.py create mode 100644 tests/e2e/management/test_user.py create mode 100644 tests/e2e/test_access_keys.py create mode 100644 tests/e2e/test_magiclink.py create mode 100644 tests/e2e/test_otp.py create mode 100644 tests/e2e/test_password.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2acb1fe9..865a0cddc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,12 @@ on: pull_request: schedule: - cron: "0 8 * * *" + workflow_dispatch: + inputs: + skip_debounce: + description: Skip the 5-minute e2e debounce (for urgent re-runs) + type: boolean + default: false concurrency: group: check-${{ github.ref }} @@ -98,7 +104,7 @@ jobs: - name: Install dependencies run: uv sync --all-extras --locked - name: Run test suite - run: uv run coverage run -m pytest tests + run: uv run coverage run -m pytest tests --ignore=tests/e2e env: COVERAGE_FILE: "coverage.${{ matrix.os }}.${{ matrix.py }}" - name: Store coverage file @@ -143,6 +149,48 @@ jobs: name: python-coverage-comment-action path: python-coverage-comment-action.txt + e2e-sandbox: + name: E2E Tests + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + if: > + github.event_name != 'pull_request' || + github.event.pull_request.draft != true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + python-version: "3.13" + - name: Install dependencies + run: uv sync --all-extras --locked + - name: Debounce + # Waits 1 min before running so rapid pushes to the same PR cancel the stale run + # (via the global cancel-in-progress) before any e2e work starts. + # Skip via the 'quick-e2e' PR label or the skip_debounce workflow_dispatch input. + if: > + github.event_name == 'pull_request' && + github.actor != 'renovate[bot]' && + !contains(github.event.pull_request.labels.*.name, 'quick-e2e') && + inputs.skip_debounce != 'true' + run: sleep 60 + - name: Run e2e tests + if: github.actor != 'renovate[bot]' + run: uv run pytest tests/e2e -v -m e2e --junitxml=e2e-report.xml + env: + DESCOPE_PROJECT_ID: ${{ secrets.DESCOPE_SANDBOX_PROJECT_ID }} + DESCOPE_MANAGEMENT_KEY: ${{ secrets.DESCOPE_SANDBOX_MANAGEMENT_KEY }} + DESCOPE_BASE_URI: ${{ secrets.DESCOPE_SANDBOX_BASE_URI }} + - name: Publish e2e test report + if: always() && github.actor != 'renovate[bot]' + uses: mikepenz/action-junit-report@3a81627bfac62268172037048872e8ebd4207e6d # v6.4.1 + with: + report_paths: e2e-report.xml + check_name: E2E Test Report + gitleaks: name: gitleaks runs-on: ubuntu-latest @@ -159,7 +207,7 @@ jobs: name: All Checks Passed runs-on: ubuntu-latest permissions: {} - needs: [validate-pr-title, lint, build, coverage, gitleaks] + needs: [validate-pr-title, lint, build, coverage, gitleaks, e2e-sandbox] if: always() && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') steps: - name: Yey, all checks passed! diff --git a/README.md b/README.md index 25129701c..da71f1fcf 100644 --- a/README.md +++ b/README.md @@ -575,12 +575,14 @@ except AuthException as e: ``` **Important Notes:** + - Verbose mode is **disabled by default** (no performance impact when not needed) - When enabled, only the **most recent** HTTP response is stored - `get_last_response()` returns `None` when verbose mode is disabled - The response object provides dict-like access to JSON data while also exposing HTTP metadata **Available metadata on response objects:** + - `response.headers` - HTTP response headers (dict-like object) - `response.status_code` - HTTP status code (int) - `response.text` - Raw response body as text (str) @@ -1914,6 +1916,33 @@ Running all tests with coverage: uv run pytest --junitxml=/tmp/pytest.xml --cov-report=term-missing:skip-covered --cov=descope tests/ --cov-report=xml:/tmp/cov.xml ``` +### Running e2e tests + +The `tests/e2e/` suite exercises the SDK against a real Descope backend. Set the +following environment variables before running: + +| Variable | Required | Description | +| ------------------------ | -------- | --------------------------------- | +| `DESCOPE_PROJECT_ID` | ✅ | The project to run tests against | +| `DESCOPE_MANAGEMENT_KEY` | ✅ | A management key for that project | +| `DESCOPE_BASE_URI` | Optional | Override the API base URL | + +**Project prerequisites:** the e2e project must be configured with: + +- **Password authentication enabled** with policy: minLength ≥ 9, uppercase required, non-alphanumeric character required (needed by `test_password.py`). +- **Magic-link / email authentication enabled** (needed by `test_magiclink.py`). +- **Default flows present** (needed by `test_flow.py`). + +```bash +uv run pytest tests/e2e -v +``` + +To run the full test suite while skipping e2e tests (default CI behaviour): + +```bash +uv run pytest tests -m "not e2e" +``` + ### Lint and format ```bash diff --git a/pyproject.toml b/pyproject.toml index c7e8e65bb..63a7ce7e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ Documentation = "https://docs.descope.com" [dependency-groups] dev = [ "pre-commit==3.8.0", + "python-dotenv>=1.2.1", "ruff==0.15.13", ] types = [ @@ -77,6 +78,9 @@ build-backend = "uv_build" [tool.pytest.ini_options] asyncio_mode = "auto" +markers = [ + "e2e: end-to-end tests against a real Descope backend (require DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY)", +] [tool.coverage.run] relative_files = true @@ -113,7 +117,8 @@ ignore = [ "descope/management/tenant.py" = ["N803"] "descope/management/user.py" = ["N803", "N806"] # Tests intentionally use blind exception checks and raw attribute access -"tests/**" = ["B017", "B018"] +# F841: context manager vars like `as mock_post` are unused by design (side-effect only) +"tests/**" = ["B017", "B018", "B028", "N801", "F841"] [tool.pylic] # License names match the long-form `License` metadata field that pylic reads diff --git a/tests/_unified.py b/tests/_unified.py new file mode 100644 index 000000000..a3e45d5bf --- /dev/null +++ b/tests/_unified.py @@ -0,0 +1,35 @@ +""" +Shared base class for unified sync/async client wrappers. + +Both the unit-test UnifiedClient (tests/conftest.py) and the e2e wrapper +(tests/e2e/conftest.py) inherit from this to share the invoke() pattern +without coupling e2e code to mock-oriented helpers. +""" + +from __future__ import annotations + +import asyncio + + +class UnifiedClientBase: + """ + Wraps a DescopeClient or DescopeClientAsync with a uniform interface. + + - ``mode`` — "sync" or "async". + - attribute access — delegated to the underlying raw client. + - ``invoke(maybe_coro)`` — awaits coroutines (async mode) or passes values + through as-is (sync mode), so test bodies are identical for both clients. + """ + + def __init__(self, mode: str, raw): + self.mode = mode # "sync" | "async" + self._raw = raw + + def __getattr__(self, name): + return getattr(self._raw, name) + + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 02f90088e..602edd55b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import os from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch @@ -10,6 +9,7 @@ from descope.common import DEFAULT_TIMEOUT_SECONDS from descope.descope_client import DescopeClient from descope.descope_client_async import DescopeClientAsync +from tests._unified import UnifiedClientBase from tests.common import DEFAULT_BASE_URL from tests.testutils import PUBLIC_KEY_DICT, SSLMatcher @@ -65,28 +65,16 @@ def make_response(json_data=None, *, status=200, cookies=None): return m -class UnifiedClient: +class UnifiedClient(UnifiedClientBase): """ 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. + - ``invoke(maybe_coro)`` — awaits async calls, passes through sync values + (inherited from UnifiedClientBase). - ``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) - - 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 - @contextmanager def mock_get(self, response): with self._patch_ctx("get", response) as m: diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 000000000..72651c7b7 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,61 @@ +""" +Fixtures for the e2e test suite. + +Requires a real Descope backend reachable at DESCOPE_BASE_URI (defaults to +https://api.descope.com). The suite fails at collection time when the required +env vars are absent. + +Required env vars: + DESCOPE_PROJECT_ID — the project to run tests against + DESCOPE_MANAGEMENT_KEY — a management key for that project + +Optional env vars: + DESCOPE_BASE_URI — override the API base URL (e.g. https://localhost:8000 + when running against a local cluster); auto toggling skip_verify +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +try: + from dotenv import load_dotenv + + load_dotenv() # populate env from .env before the env-var check below +except ImportError: # python-dotenv is a dev-only extra; tolerate its absence + pass + +from descope import DescopeClient # noqa: E402 +from descope.descope_client_async import DescopeClientAsync # noqa: E402 +from tests._unified import UnifiedClientBase # noqa: E402 + +if not os.environ.get("DESCOPE_PROJECT_ID") or not os.environ.get("DESCOPE_MANAGEMENT_KEY"): + print( + "ERROR: DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set to run e2e tests", + file=sys.stderr, + ) + pytest.exit("Missing required e2e environment variables", returncode=1) + + +@pytest.fixture(params=["sync", "async"]) +async def descope_client(request): # type: ignore[misc] + """ + Parametrized fixture — yields a UnifiedClientBase wrapping DescopeClient (sync) + or DescopeClientAsync (async) against a real backend. Each consuming test runs twice. + """ + project_id = os.environ["DESCOPE_PROJECT_ID"] + management_key = os.environ["DESCOPE_MANAGEMENT_KEY"] + + skip_verify = "localhost" in os.environ.get("DESCOPE_BASE_URI", "") + if request.param == "sync": + yield UnifiedClientBase( + "sync", + DescopeClient(project_id=project_id, management_key=management_key, skip_verify=skip_verify), + ) + else: + raw = DescopeClientAsync(project_id=project_id, management_key=management_key, skip_verify=skip_verify) + yield UnifiedClientBase("async", raw) + await raw.aclose() diff --git a/tests/e2e/management/__init__.py b/tests/e2e/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/management/test_descoper.py b/tests/e2e/management/test_descoper.py new file mode 100644 index 000000000..3dd332b74 --- /dev/null +++ b/tests/e2e/management/test_descoper.py @@ -0,0 +1,64 @@ +"""E2E test: descoper (team-member) CRUD.""" + +import os +import uuid + +import pytest + +from descope import DescoperAttributes, DescoperCreate, DescoperProjectRole, DescoperRBAC, DescoperRole + +pytestmark = [pytest.mark.e2e, pytest.mark.skip(reason="requires company-level management key permissions")] + + +class TestE2E_ManagementDescoper: + async def test_descoper_crud(self, descope_client): + project_id = os.environ.get("DESCOPE_PROJECT_ID", "") + login_id = f"descoper-{uuid.uuid4()}@example.com" + + # --- Create --- + attributes = DescoperAttributes(display_name="Test Descoper", email=login_id, phone="+1234567890") + rbac = DescoperRBAC(projects=[DescoperProjectRole(project_ids=[project_id], role=DescoperRole.DEVELOPER)]) + descoper_create = DescoperCreate(login_id=login_id, attributes=attributes, send_invite=False, rbac=rbac) + create_resp = await descope_client.invoke(descope_client.mgmt.descoper.create([descoper_create])) + + descopers = create_resp["descopers"] + assert descopers + assert len(descopers) == 1 + descoper = descopers[0] + descoper_id = descoper["id"] + assert descoper_id + assert descoper["loginIDs"] == [login_id] + + try: + # --- Load --- + load_resp = await descope_client.invoke(descope_client.mgmt.descoper.load(descoper_id)) + loaded = load_resp["descoper"] + assert loaded + assert loaded["id"] == descoper_id + + # --- List --- + list_resp = await descope_client.invoke(descope_client.mgmt.descoper.list()) + descopers_list = list_resp["descopers"] + assert descopers_list + assert any(d["id"] == descoper_id for d in descopers_list) + + # --- Update --- + updated_attributes = DescoperAttributes( + display_name="Updated Descoper", email=login_id, phone="+0987654321" + ) + updated_rbac = DescoperRBAC(is_company_admin=True) + update_resp = await descope_client.invoke( + descope_client.mgmt.descoper.update(id=descoper_id, attributes=updated_attributes, rbac=updated_rbac) + ) + assert update_resp + + # --- Verify update --- + verify_resp = await descope_client.invoke(descope_client.mgmt.descoper.load(descoper_id)) + updated = verify_resp["descoper"] + assert updated["id"] == descoper_id + assert updated["attributes"]["displayName"] == "Updated Descoper" + assert updated["attributes"]["phone"] == "+0987654321" + assert updated["rbac"]["isCompanyAdmin"] + + finally: + await descope_client.invoke(descope_client.mgmt.descoper.delete(descoper_id)) diff --git a/tests/e2e/management/test_flow.py b/tests/e2e/management/test_flow.py new file mode 100644 index 000000000..e1675d9a3 --- /dev/null +++ b/tests/e2e/management/test_flow.py @@ -0,0 +1,14 @@ +"""E2E test: list flows.""" + +import pytest + +pytestmark = pytest.mark.e2e + + +class TestE2E_ListFlows: + async def test_list_flows(self, descope_client): + flows_resp = await descope_client.invoke(descope_client.mgmt.flow.list_flows()) + flows = flows_resp["flows"] + total = flows_resp["total"] + assert total == len(flows) + assert total > 0 diff --git a/tests/e2e/management/test_jwt.py b/tests/e2e/management/test_jwt.py new file mode 100644 index 000000000..ff6c4b64e --- /dev/null +++ b/tests/e2e/management/test_jwt.py @@ -0,0 +1,27 @@ +"""E2E test: management JWT sign-in.""" + +import uuid + +import pytest + +pytestmark = pytest.mark.e2e + + +class TestE2E_ManagementJWT: + async def test_management_jwt_sign_in(self, descope_client): + login_id = f"jwt-user-{uuid.uuid4()}@example.com" + + try: + await descope_client.invoke(descope_client.mgmt.user.create(login_id)) + + resp = await descope_client.invoke(descope_client.mgmt.jwt.sign_in(login_id)) + + session_token = resp["sessionToken"] + assert session_token + assert session_token["jwt"] + + finally: + try: + await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) + except Exception: + pass diff --git a/tests/e2e/management/test_mgmtkey.py b/tests/e2e/management/test_mgmtkey.py new file mode 100644 index 000000000..1b6086a82 --- /dev/null +++ b/tests/e2e/management/test_mgmtkey.py @@ -0,0 +1,73 @@ +"""E2E test: management key CRUD.""" + +import uuid + +import pytest + +from descope import MgmtKeyReBac, MgmtKeyStatus + +pytestmark = [pytest.mark.e2e, pytest.mark.skip(reason="requires company-level management key permissions")] + + +class TestE2E_ManagementKey: + async def test_management_key_crud(self, descope_client): + key_name = f"test-key-{uuid.uuid4().hex[:10]}" + + # --- Create --- + rebac = MgmtKeyReBac(company_roles=["company-full-access"]) + create_resp = await descope_client.invoke( + descope_client.mgmt.management_key.create( + name=key_name, + rebac=rebac, + description="Test management key", + expires_in=0, + permitted_ips=["10.0.0.1"], + ) + ) + + assert "key" in create_resp + assert "cleartext" in create_resp + key = create_resp["key"] + key_id = key["id"] + assert key_id + assert key["name"] == key_name + assert key["description"] == "Test management key" + assert "10.0.0.1" in key["permittedIps"] + + # --- Load --- + load_resp = await descope_client.invoke(descope_client.mgmt.management_key.load(key_id)) + key = load_resp["key"] + assert key + assert key["id"] == key_id + assert key["name"] == key_name + + # --- Search --- + search_resp = await descope_client.invoke(descope_client.mgmt.management_key.search()) + keys = search_resp["keys"] + assert keys + assert any(k["id"] == key_id for k in keys) + + # --- Update --- + updated_name = key_name + "_updated" + update_resp = await descope_client.invoke( + descope_client.mgmt.management_key.update( + id=key_id, + name=updated_name, + description="Updated test management key", + permitted_ips=["10.0.0.2"], + status=MgmtKeyStatus.ACTIVE, + ) + ) + assert update_resp + + # --- Verify update --- + verify_resp = await descope_client.invoke(descope_client.mgmt.management_key.load(key_id)) + key = verify_resp["key"] + assert key["id"] == key_id + assert key["name"] == updated_name + assert key["description"] == "Updated test management key" + assert "10.0.0.2" in key["permittedIps"] + + # --- Delete --- + delete_resp = await descope_client.invoke(descope_client.mgmt.management_key.delete([key_id])) + assert delete_resp["total"] == 1 diff --git a/tests/e2e/management/test_project.py b/tests/e2e/management/test_project.py new file mode 100644 index 000000000..8c23943e3 --- /dev/null +++ b/tests/e2e/management/test_project.py @@ -0,0 +1,21 @@ +"""E2E test: management project capabilities (list + tag update). +Does NOT create or delete projects. +""" + +import pytest + +pytestmark = [pytest.mark.e2e, pytest.mark.skip(reason="requires company-level management key permissions")] + + +class TestE2E_ManagementProject: + async def test_management_project_capabilities(self, descope_client): + list_resp = await descope_client.invoke(descope_client.mgmt.project.list_projects()) + projects = list_resp["projects"] + assert projects, "Expected at least one project in the list" + + current_tags = projects[0].get("tags", []) if projects else [] + + try: + await descope_client.invoke(descope_client.mgmt.project.update_tags(["e2e-test-tag1", "e2e-test-tag2"])) + finally: + await descope_client.invoke(descope_client.mgmt.project.update_tags(current_tags)) diff --git a/tests/e2e/management/test_sso.py b/tests/e2e/management/test_sso.py new file mode 100644 index 000000000..2e1ac9573 --- /dev/null +++ b/tests/e2e/management/test_sso.py @@ -0,0 +1,283 @@ +"""E2E test: SSO settings (OIDC and SAML). +""" + +import os +import uuid + +import pytest + +from descope import ( + AttributeMapping, + OIDCAttributeMapping, + RoleMapping, + SSOOIDCSettings, + SSOSAMLSettings, + SSOSAMLSettingsByMetadata, +) + +pytestmark = pytest.mark.e2e + + +class TestE2E_ManagementSSO: + async def test_management_sso_capabilities_oidc_settings(self, descope_client): + tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" + tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] + + try: + settings = SSOOIDCSettings( + name="myProvider", + client_id="iddd", + client_secret="secret", + auth_url="https://dummy.com/auth", + token_url="https://dummy.com/token", + user_data_url="https://dummy.com/userInfo", + scope=["openid", "profile", "email"], + attribute_mapping=OIDCAttributeMapping( + login_id="subject", + name="name", + given_name="givenName", + middle_name="middleName", + family_name="familyName", + email="email", + verified_email="verifiedEmail", + username="username", + phone_number="phoneNumber", + verified_phone="verifiedPhone", + picture="picture", + ), + ) + await descope_client.invoke(descope_client.mgmt.sso.configure_oidc_settings(tid, settings)) + + res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) + oidc = res["oidc"] + + assert oidc["name"] == "myProvider" + assert oidc["clientId"] == "iddd" + assert oidc["clientSecret"] == "" # redacted by backend + assert oidc["authUrl"] == "https://dummy.com/auth" + assert oidc["tokenUrl"] == "https://dummy.com/token" + assert oidc["userDataUrl"] == "https://dummy.com/userInfo" + assert oidc["scope"] == ["openid", "profile", "email"] + + mapping = oidc["userAttrMapping"] + assert mapping["loginId"] == "subject" + assert mapping["name"] == "name" + assert mapping["givenName"] == "givenName" + assert mapping["middleName"] == "middleName" + assert mapping["familyName"] == "familyName" + assert mapping["email"] == "email" + assert mapping["verifiedEmail"] == "verifiedEmail" + assert mapping["username"] == "username" + assert mapping["phoneNumber"] == "phoneNumber" + assert mapping["verifiedPhone"] == "verifiedPhone" + assert mapping["picture"] == "picture" + finally: + await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) + + async def test_management_sso_capabilities_saml_settings(self, descope_client): + project_id = os.environ.get("DESCOPE_PROJECT_ID", "") + role_name = f"sso-role-{uuid.uuid4().hex[:10]}" + tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" + + await descope_client.invoke(descope_client.mgmt.role.create(role_name)) + tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] + + try: + settings = SSOSAMLSettings( + idp_url="https://dummy.com/saml", + idp_entity_id="entity1234", + idp_cert="my certificate", + attribute_mapping=AttributeMapping( + name="name", + given_name="givenName", + middle_name="middleName", + family_name="familyName", + picture="picture", + email="email", + phone_number="phoneNumber", + group="groups", + ), + role_mappings=[RoleMapping(groups=["grp1"], role_name=role_name)], + ) + await descope_client.invoke(descope_client.mgmt.sso.configure_saml_settings(tid, settings)) + + res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) + saml = res["saml"] + + assert saml["idpSSOUrl"] == "https://dummy.com/saml" + assert saml["idpEntityId"] == "entity1234" + assert saml["idpCertificate"] == "my certificate" + assert saml["spEntityId"] == f"{project_id}-{tid}" + assert f"projectId={project_id}" in saml["spACSUrl"] + assert f"tenantId={tid}" in saml["spACSUrl"] + + attr_map = saml["attributeMapping"] + assert attr_map["group"] == "groups" + + groups_mapping = saml["groupsMapping"] + assert len(groups_mapping) == 1 + assert groups_mapping[0]["role"]["name"] == role_name + assert groups_mapping[0]["groups"] == ["grp1"] + finally: + await descope_client.invoke(descope_client.mgmt.role.delete(role_name)) + await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) + + async def test_management_sso_capabilities_saml_settings_by_metadata(self, descope_client): + project_id = os.environ.get("DESCOPE_PROJECT_ID", "") + role_name = f"sso-role-{uuid.uuid4().hex[:10]}" + tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" + + await descope_client.invoke(descope_client.mgmt.role.create(role_name)) + tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] + + try: + settings = SSOSAMLSettingsByMetadata( + idp_metadata_url="https://dummy.com/saml", + attribute_mapping=AttributeMapping( + name="name", + given_name="givenName", + middle_name="middleName", + family_name="familyName", + picture="picture", + email="email", + phone_number="phoneNumber", + group="groups", + ), + role_mappings=[RoleMapping(groups=["grp1"], role_name=role_name)], + ) + await descope_client.invoke(descope_client.mgmt.sso.configure_saml_settings_by_metadata(tid, settings)) + + res = await descope_client.invoke(descope_client.mgmt.sso.load_settings(tid)) + saml = res["saml"] + + assert saml["idpMetadataUrl"] == "https://dummy.com/saml" + assert saml["spEntityId"] == f"{project_id}-{tid}" + assert f"projectId={project_id}" in saml["spACSUrl"] + assert f"tenantId={tid}" in saml["spACSUrl"] + + attr_map = saml["attributeMapping"] + assert attr_map["group"] == "groups" + + groups_mapping = saml["groupsMapping"] + assert len(groups_mapping) == 1 + assert groups_mapping[0]["role"]["name"] == role_name + assert groups_mapping[0]["groups"] == ["grp1"] + finally: + await descope_client.invoke(descope_client.mgmt.role.delete(role_name)) + await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) + + async def test_management_sso_capabilities(self, descope_client): + # NOTE: These APIs are deprecated; retained for backwards-compatibility parity. + project_id = os.environ.get("DESCOPE_PROJECT_ID", "") + tenant_name = f"sso-tenant-{uuid.uuid4().hex[:10]}" + tid = (await descope_client.invoke(descope_client.mgmt.tenant.create(tenant_name)))["id"] + + try: + loaded = await descope_client.invoke(descope_client.mgmt.tenant.load(tid)) + assert loaded["id"] == tid + assert loaded["name"] == tenant_name + + # --- Initial empty state --- + settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) + assert settings["tenantId"] + assert settings["idpEntityId"] == "" + assert settings["idpSSOUrl"] == "" + assert settings["idpCertificate"] == "" + assert settings.get("idpMetadataUrl", "") == "" + assert settings["spEntityId"] == f"{project_id}-{tid}" + assert f"projectId={project_id}" in settings["spACSUrl"] + assert f"tenantId={tid}" in settings["spACSUrl"] + assert settings.get("groupsMapping", []) == [] + assert settings.get("redirectUrl", "") == "" + assert settings.get("domain", "") == "" + assert settings.get("domains", []) == [] + assert settings.get("userMapping", {}) == { + "name": "name", + "email": "email", + "username": "", + "phoneNumber": "phone", + "group": "", + "givenName": "", + "middleName": "", + "familyName": "", + "picture": "", + "verifiedEmail": "", + "verifiedPhone": "", + "customAttributes": {}, + } + + await descope_client.invoke( + descope_client.mgmt.sso.configure( + tid, + "http://idpURL", + "entity", + "mycert", + "https://redirect", + ["domain.com", "app.domain.com"], + ) + ) + + settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) + assert settings["idpEntityId"] == "entity" + assert settings["idpSSOUrl"] == "http://idpURL" + assert settings["idpCertificate"] == "mycert" + assert settings["spEntityId"] == f"{project_id}-{tid}" + assert f"projectId={project_id}" in settings["spACSUrl"] + assert f"tenantId={tid}" in settings["spACSUrl"] + assert settings["redirectUrl"] == "https://redirect" + assert settings.get("domain", "") == "domain.com" + assert "domain.com" in settings["domains"] + assert "app.domain.com" in settings["domains"] + + await descope_client.invoke( + descope_client.mgmt.sso.configure_via_metadata( + tid, + "http://idpMetadataURL", + domains=["domain2.com", "app.domain2.com"], + ) + ) + + await descope_client.invoke( + descope_client.mgmt.sso.mapping( + tid, + role_mappings=[RoleMapping(["a"], "Tenant Admin")], + attribute_mapping=AttributeMapping(name="MyName"), + ) + ) + + settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) + assert settings.get("idpMetadataUrl") == "http://idpMetadataURL" + assert settings["spEntityId"] == f"{project_id}-{tid}" + assert f"projectId={project_id}" in settings["spACSUrl"] + assert f"tenantId={tid}" in settings["spACSUrl"] + assert settings.get("redirectUrl", "") == "https://redirect" + assert settings.get("domain", "") == "domain2.com" + assert "domain2.com" in settings["domains"] + user_mapping = settings.get("userMapping", {}) + assert user_mapping == { + "name": "MyName", + "email": "", + "username": "", + "phoneNumber": "", + "group": "", + "givenName": "", + "middleName": "", + "familyName": "", + "picture": "", + "verifiedEmail": "", + "verifiedPhone": "", + "customAttributes": {}, + } + groups_mapping = settings.get("groupsMapping", []) + assert len(groups_mapping) == 1 + assert groups_mapping[0]["role"]["name"] == "Tenant Admin" + assert groups_mapping[0]["groups"] == ["a"] + + await descope_client.invoke(descope_client.mgmt.sso.delete_settings(tid)) + + settings = await descope_client.invoke(descope_client.mgmt.sso.get_settings(tid)) + assert settings["idpEntityId"] == "" + assert settings["idpSSOUrl"] == "" + assert settings["idpCertificate"] == "" + finally: + await descope_client.invoke(descope_client.mgmt.tenant.delete(tid)) diff --git a/tests/e2e/management/test_user.py b/tests/e2e/management/test_user.py new file mode 100644 index 000000000..299bf1bec --- /dev/null +++ b/tests/e2e/management/test_user.py @@ -0,0 +1,194 @@ +"""E2E test: user management CRUD + test-user flow + invite. +""" + +import uuid + +import pytest + +from descope import AuthException, DeliveryMethod +from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME +from descope.management.user import UserObj + +pytestmark = pytest.mark.e2e + + +class TestE2E_ManagementUser: + @pytest.fixture(autouse=True) + async def _cleanup(self, descope_client): + yield + await descope_client.invoke(descope_client.mgmt.user.delete_all_test_users()) + + async def test_management_user_capabilities(self, descope_client): + user_login_id = f"des-{uuid.uuid4().hex[:8]}@copeland.com" + invited_login_id = f"des-invited-{uuid.uuid4().hex[:8]}@copeland.com" + invited1_login_id = f"des-1-invited-{uuid.uuid4().hex[:8]}@copeland.com" + invited2_login_id = f"des-2-invited-{uuid.uuid4().hex[:8]}@copeland.com" + test_user_login_id = f"test-{uuid.uuid4().hex[:10]}" + role1_name = f"role-{uuid.uuid4().hex[:8]}" + role2_name = f"role-{uuid.uuid4().hex[:8]}" + updated_login_id: str = "" + + try: + # ---------------------------------------------------------------- + # User CRUD + # ---------------------------------------------------------------- + resp = await descope_client.invoke(descope_client.mgmt.user.create(user_login_id)) + user = resp["user"] + assert user + assert user["email"] == user_login_id + assert user["verifiedEmail"] + assert user["status"] == "invited" + + resp = await descope_client.invoke(descope_client.mgmt.user.load(user_login_id)) + assert resp["user"]["email"] == user_login_id + assert resp["user"]["verifiedEmail"] + assert resp["user"]["status"] == "invited" + + new_login_id = f"bane-{uuid.uuid4().hex[:8]}@copeland.com" + resp = await descope_client.invoke(descope_client.mgmt.user.update_login_id(user_login_id, new_login_id)) + updated_login_id = new_login_id + assert updated_login_id in resp["user"]["loginIds"] + + search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all()) + users = search_resp.get("users", []) + assert any(updated_login_id in (u.get("loginIds") or []) for u in users) + + await descope_client.invoke( + descope_client.mgmt.user.update(updated_login_id, display_name="Desmond Copeland") + ) + + await descope_client.invoke(descope_client.mgmt.role.create(role1_name)) + await descope_client.invoke(descope_client.mgmt.role.create(role2_name)) + + await descope_client.invoke( + descope_client.mgmt.user.patch( + updated_login_id, + phone="+1234567890", + middle_name="Middle", + role_names=[role1_name], + ) + ) + resp = await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)) + u = resp["user"] + assert updated_login_id in u["loginIds"] + assert u["name"] == "Desmond Copeland" + assert u.get("middleName") == "Middle" + assert u.get("phone") == "+1234567890" + assert role1_name in u.get("roleNames", []) + assert u.get("verifiedPhone") + + await descope_client.invoke( + descope_client.mgmt.user.patch(updated_login_id, role_names=[role1_name, role2_name]) + ) + u = (await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)))["user"] + role_names_list = u.get("roleNames", []) + assert len(role_names_list) == 2 + assert role1_name in role_names_list + assert role2_name in role_names_list + + await descope_client.invoke(descope_client.mgmt.user.patch(updated_login_id, role_names=[])) + u = (await descope_client.invoke(descope_client.mgmt.user.load(updated_login_id)))["user"] + assert len(u.get("roleNames", [])) == 0 + assert u.get("phone") == "+1234567890" + + await descope_client.invoke(descope_client.mgmt.user.delete(updated_login_id)) + search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all()) + assert not any(updated_login_id in (u.get("loginIds") or []) for u in search_resp.get("users", [])) + + # ---------------------------------------------------------------- + # Test-user flow + # ---------------------------------------------------------------- + with pytest.raises(AuthException): + await descope_client.invoke( + descope_client.mgmt.user.generate_otp_for_test_user( + method=DeliveryMethod.EMAIL, + login_id=test_user_login_id, + ) + ) + + create_resp = await descope_client.invoke( + descope_client.mgmt.user.create_test_user( + login_id=test_user_login_id, + email="doron@google.com", + phone="+972-52-5554321", + display_name="foo bar test", + ) + ) + test_user = create_resp["user"] + assert test_user["loginIds"][0] == test_user_login_id + test_user_id = test_user["userId"] + + resp = await descope_client.invoke(descope_client.mgmt.user.load_by_user_id(test_user_id)) + assert resp["user"] + + search_resp = await descope_client.invoke(descope_client.mgmt.user.search_all(test_users_only=True)) + test_users = search_resp.get("users", []) + assert len(test_users) == 1 + assert test_users[0]["userId"] == test_user_id + + gen_resp = await descope_client.invoke( + descope_client.mgmt.user.generate_otp_for_test_user( + method=DeliveryMethod.EMAIL, + login_id=test_user_login_id, + ) + ) + code = gen_resp["code"] + assert code + assert gen_resp["loginId"] == test_user_login_id + + jwt_response = await descope_client.invoke( + descope_client.otp.verify_code( + method=DeliveryMethod.EMAIL, + login_id=test_user_login_id, + code=code, + ) + ) + assert jwt_response.get("firstSeen") + assert jwt_response[SESSION_TOKEN_NAME]["jwt"] + assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] + assert jwt_response["user"]["userId"] == test_user_id + + await descope_client.invoke(descope_client.mgmt.user.delete_all_test_users()) + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.mgmt.user.load_by_user_id(test_user_id)) + + # ---------------------------------------------------------------- + # Invite flow + # ---------------------------------------------------------------- + resp = await descope_client.invoke(descope_client.mgmt.user.invite(invited_login_id)) + u = resp["user"] + assert u["email"] == invited_login_id + assert u["verifiedEmail"] + assert u["status"] == "invited" + + resp = await descope_client.invoke(descope_client.mgmt.user.load(invited_login_id)) + assert resp["user"]["email"] == invited_login_id + assert resp["user"]["verifiedEmail"] + assert resp["user"]["status"] == "invited" + + batch_resp = await descope_client.invoke( + descope_client.mgmt.user.invite_batch([UserObj(invited1_login_id), UserObj(invited2_login_id)]) + ) + created = batch_resp["createdUsers"] + failed = batch_resp["failedUsers"] + assert len(created) == 2 + assert len(failed) == 0 + for cu in created: + assert cu["email"] + assert cu["verifiedEmail"] + assert cu["status"] == "invited" + + await descope_client.invoke(descope_client.mgmt.user.load(invited1_login_id)) + await descope_client.invoke(descope_client.mgmt.user.load(invited2_login_id)) + + finally: + for rname in [role1_name, role2_name]: + try: + await descope_client.invoke(descope_client.mgmt.role.delete(rname)) + except Exception: + pass + for uid in [invited_login_id, invited1_login_id, invited2_login_id]: + try: + await descope_client.invoke(descope_client.mgmt.user.delete(uid)) + except Exception: + pass diff --git a/tests/e2e/test_access_keys.py b/tests/e2e/test_access_keys.py new file mode 100644 index 000000000..f5c2971fd --- /dev/null +++ b/tests/e2e/test_access_keys.py @@ -0,0 +1,97 @@ +"""E2E test: access key exchange and CRUD.""" + +import os + +import pytest + +from descope import AccessKeyLoginOptions +from descope.common import SESSION_TOKEN_NAME + +pytestmark = pytest.mark.e2e + + +class TestE2E_AccessKeys: + async def test_exchange_access_key(self, descope_client): + key1_id = None + key2_id = None + try: + # Create AK1 with custom claims and description + resp1 = await descope_client.invoke( + descope_client.mgmt.access_key.create( + name="AK1", + custom_claims={"k1": "v1"}, + description="hey", + ) + ) + access_key_cleartext = resp1["cleartext"] + key1 = resp1["key"] + key1_id = key1["id"] + assert key1["permittedIps"] == [] + assert key1["description"] == "hey" + + # Create AK2 with permitted IPs + resp2 = await descope_client.invoke( + descope_client.mgmt.access_key.create( + name="AK2", + custom_claims={"k1": "v1"}, + permitted_ips=["10.0.0.1", "192.168.1.0/24"], + ) + ) + key2 = resp2["key"] + key2_id = key2["id"] + assert key2["permittedIps"] == ["10.0.0.1", "192.168.1.0/24"] + assert key2["description"] == "" + + # Exchange AK1 with custom claims in login options + loc = AccessKeyLoginOptions(custom_claims={"nsec-k1": "nsec-v1"}) + jwt_response = await descope_client.invoke( + descope_client.exchange_access_key( + access_key=access_key_cleartext, + login_options=loc, + ) + ) + assert jwt_response, "exchange_access_key returned empty response" + + token = jwt_response[SESSION_TOKEN_NAME] + assert token["k1"] == "v1" + assert token["nsec"] is not None + assert token["nsec"]["nsec-k1"] == "nsec-v1" + assert jwt_response["projectId"] == os.environ.get("DESCOPE_PROJECT_ID", "") + finally: + if key1_id: + await descope_client.invoke(descope_client.mgmt.access_key.delete(key1_id)) + if key2_id: + await descope_client.invoke(descope_client.mgmt.access_key.delete(key2_id)) + + async def test_update_access_key(self, descope_client): + key_id = None + try: + # Create + resp = await descope_client.invoke(descope_client.mgmt.access_key.create(name="AAA", description="hey")) + key = resp["key"] + key_id = key["id"] + assert key["name"] == "AAA" + assert key["description"] == "hey" + + # Update name and description + await descope_client.invoke( + descope_client.mgmt.access_key.update(id=key_id, name="ABA", description="hello there") + ) + + # Load and verify update + resp = await descope_client.invoke(descope_client.mgmt.access_key.load(key_id)) + key = resp["key"] + assert key["name"] == "ABA" + assert key["description"] == "hello there" + + # Update with description=None — existing description must be preserved + await descope_client.invoke(descope_client.mgmt.access_key.update(id=key_id, name="ABA1", description=None)) + + # Load and verify name changed but description unchanged + resp = await descope_client.invoke(descope_client.mgmt.access_key.load(key_id)) + key = resp["key"] + assert key["name"] == "ABA1" + assert key["description"] == "hello there" + finally: + if key_id: + await descope_client.invoke(descope_client.mgmt.access_key.delete(key_id)) diff --git a/tests/e2e/test_magiclink.py b/tests/e2e/test_magiclink.py new file mode 100644 index 000000000..873779608 --- /dev/null +++ b/tests/e2e/test_magiclink.py @@ -0,0 +1,106 @@ +"""E2E test: magic-link sign-up, sign-in, and session flow.""" + +import uuid + +import pytest + +from descope import DeliveryMethod +from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME + +pytestmark = pytest.mark.e2e + + +def _extract_project_id_from_iss(iss: str) -> str: + """Return the last path component of the 'iss' JWT claim.""" + return iss.rsplit("/", 1)[-1] + + +class TestE2E_Magiclink: + @pytest.fixture(autouse=True) + async def _cleanup(self, descope_client): + """Delete all test users after each test.""" + yield + await descope_client.invoke(descope_client.mgmt.user.delete_all_test_users()) + + async def test_magiclink_methods(self, descope_client): + login_id = f"user-{uuid.uuid4()}" + uri = "http://test.me" + + # Create a test user + await descope_client.invoke( + descope_client.mgmt.user.create_test_user( + login_id=login_id, + email="doron@google.com", + phone="+972-52-5554321", + display_name="foo bar test", + ) + ) + + # --- Sign-up via magic link --- + td = await descope_client.invoke( + descope_client.mgmt.user.generate_magic_link_for_test_user( + method=DeliveryMethod.EMAIL, + login_id=login_id, + uri=uri, + ) + ) + raw_link = td["link"] + token = raw_link[raw_link.index("=") + 1 :] + + await descope_client.invoke( + descope_client.magiclink.sign_up_or_in( + method=DeliveryMethod.EMAIL, + login_id=login_id, + uri=uri, + ) + ) + + jwt_response = await descope_client.invoke(descope_client.magiclink.verify(token=token)) + assert jwt_response, "magiclink.verify after sign-up returned empty response" + + refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] + assert refresh_token, "Refresh token is empty after sign-up" + + # --- Sign-in via magic link --- + td2 = await descope_client.invoke( + descope_client.mgmt.user.generate_magic_link_for_test_user( + method=DeliveryMethod.EMAIL, + login_id=login_id, + uri=uri, + ) + ) + raw_link2 = td2["link"] + sign_in_token = raw_link2[raw_link2.index("=") + 1 :] + + await descope_client.invoke( + descope_client.magiclink.sign_in( + method=DeliveryMethod.EMAIL, + login_id=login_id, + uri=uri, + ) + ) + + jwt_response = await descope_client.invoke(descope_client.magiclink.verify(token=sign_in_token)) + session_token = jwt_response[SESSION_TOKEN_NAME]["jwt"] + assert session_token, "Session token is empty after sign-in" + refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] + assert refresh_token, "Refresh token is empty after sign-in" + + # --- Validate & refresh session --- + await descope_client.invoke(descope_client.validate_session(session_token)) + + refreshed = await descope_client.invoke(descope_client.refresh_session(refresh_token)) + assert refreshed, "refresh_session returned empty response" + + new_session_token = refreshed[SESSION_TOKEN_NAME]["jwt"] + assert new_session_token, "New session token is empty after refresh" + + resp = await descope_client.invoke(descope_client.validate_session(new_session_token)) + assert resp, "validate_session returned empty response for refreshed token" + + token_data = resp[SESSION_TOKEN_NAME] + assert token_data["sub"] == resp["userId"] + assert _extract_project_id_from_iss(token_data["iss"]) == resp["projectId"] + + # --- Logout --- + await descope_client.invoke(descope_client.logout(refresh_token)) diff --git a/tests/e2e/test_otp.py b/tests/e2e/test_otp.py new file mode 100644 index 000000000..25989f781 --- /dev/null +++ b/tests/e2e/test_otp.py @@ -0,0 +1,102 @@ +"""E2E test: OTP sign-up / sign-in flow. + +Runs against a real Descope backend; skipped when env vars are absent. +Exercises both the sync DescopeClient and async DescopeClientAsync via the +UnifiedClientBase.invoke() pattern. +""" + +import uuid + +import pytest + +from descope import DeliveryMethod +from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME + +pytestmark = pytest.mark.e2e + + +class TestE2E_OTP: + @pytest.fixture(autouse=True) + async def _cleanup(self, descope_client): + """Delete all test users after each test.""" + yield + await descope_client.invoke(descope_client.mgmt.user.delete_all_test_users()) + + async def test_otp_sign_up_and_sign_in(self, descope_client): + login_id = f"user-{uuid.uuid4()}" + + # Create a test user with a phone number for SMS OTP + await descope_client.invoke( + descope_client.mgmt.user.create_test_user( + login_id=login_id, + phone="+972-52-5554321", + display_name="E2E OTP Test User", + ) + ) + + # --- Sign-up --- + # Pre-generate the OTP code before initiating sign-up so it is ready + generate_res = await descope_client.invoke( + descope_client.mgmt.user.generate_otp_for_test_user( + method=DeliveryMethod.SMS, + login_id=login_id, + ) + ) + await descope_client.invoke( + descope_client.otp.sign_up( + method=DeliveryMethod.SMS, + login_id=login_id, + user={"name": "E2E OTP Test User", "phone": "+972-52-5554321"}, + ) + ) + code = generate_res.get("code", "") + assert code, "OTP code returned by generate_otp_for_test_user was empty" + + jwt_response = await descope_client.invoke( + descope_client.otp.verify_code( + method=DeliveryMethod.SMS, + login_id=login_id, + code=code, + ) + ) + assert jwt_response, "verify_code after sign-up returned empty response" + + # --- Sign-in --- + generate_res = await descope_client.invoke( + descope_client.mgmt.user.generate_otp_for_test_user( + method=DeliveryMethod.SMS, + login_id=login_id, + ) + ) + await descope_client.invoke( + descope_client.otp.sign_in( + method=DeliveryMethod.SMS, + login_id=login_id, + ) + ) + code = generate_res.get("code", "") + assert code, "OTP code returned by generate_otp_for_test_user was empty" + + jwt_response = await descope_client.invoke( + descope_client.otp.verify_code( + method=DeliveryMethod.SMS, + login_id=login_id, + code=code, + ) + ) + assert jwt_response, "verify_code after sign-in returned empty response" + + session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt") + assert session_token, "Session token is empty after sign-in" + refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt") + assert refresh_token, "Refresh token is empty after sign-in" + + # --- Validate & refresh session --- + # validate_session is sync on both clients (inherited from _client_base) + await descope_client.invoke(descope_client.validate_session(session_token)) + + refreshed = await descope_client.invoke(descope_client.refresh_session(refresh_token)) + assert refreshed, "refresh_session returned empty response" + + # --- Logout --- + await descope_client.invoke(descope_client.logout(refresh_token)) diff --git a/tests/e2e/test_password.py b/tests/e2e/test_password.py new file mode 100644 index 000000000..9c9ba4d7c --- /dev/null +++ b/tests/e2e/test_password.py @@ -0,0 +1,114 @@ +"""E2E test: password sign-up, sign-in, and management flows. + +Prerequisites: the e2e project must have password authentication enabled with a +policy of minLength >= 9, uppercase required, non-alphanumeric required. + +Deferred: test_password_reset (requires MailSlurp — not ported). +""" + +import uuid + +import pytest + +from descope import AuthException +from descope.common import REFRESH_SESSION_TOKEN_NAME, SESSION_TOKEN_NAME + +pytestmark = pytest.mark.e2e + +_PASSWORD = "WASD+ijkl" + + +class TestE2E_Password: + async def test_password_sign_up(self, descope_client): + login_id = f"pw-user-{uuid.uuid4()}@example.com" + try: + # Verify the project policy is configured as required by this test + policy = await descope_client.invoke(descope_client.password.get_policy()) + assert policy["minLength"] > 5, f"sandbox password policy: minLength must be > 5, got {policy['minLength']}" + assert policy.get("nonAlphanumeric"), ( + f"sandbox password policy: nonAlphanumeric must be enabled (got {policy})" + ) + + # Negative: too-short password + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_up(login_id, "A!a4", {"name": "Test User"})) + + # Negative: no non-alphanumeric character + with pytest.raises(AuthException): + await descope_client.invoke( + descope_client.password.sign_up(login_id, "Aaa456789", {"name": "Test User"}) + ) + + # Success: valid sign-up + jwt_response = await descope_client.invoke( + descope_client.password.sign_up(login_id, _PASSWORD, {"name": "Test User"}) + ) + assert jwt_response, "password.sign_up returned empty response" + assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-up" + assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], "Refresh token missing after sign-up" + assert jwt_response.get("firstSeen") is True + + # Duplicate sign-up must raise + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_up(login_id, _PASSWORD)) + + # Negative sign-ins + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_in("bar@foo.com", _PASSWORD)) + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_in(login_id, "")) + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_in(login_id, "asdf")) + + # Success: sign-in + jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, _PASSWORD)) + assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in" + assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], "Refresh token missing after sign-in" + assert jwt_response.get("firstSeen") is False + assert jwt_response.get("user", {}).get("name") == "Test User" + refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] + + # Replace password + new_password = _PASSWORD + "a" + await descope_client.invoke(descope_client.password.replace(login_id, _PASSWORD, new_password)) + + # Sign in with new password + jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, new_password)) + assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in with new password" + assert jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"], ( + "Refresh token missing after sign-in with new password" + ) + assert jwt_response.get("firstSeen") is False + refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME]["jwt"] + + await descope_client.invoke(descope_client.logout(refresh_token)) + finally: + try: + await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) + except Exception: + pass + + async def test_set_temporary_and_active_password(self, descope_client): + login_id = f"pw-user-{uuid.uuid4()}@example.com" + try: + # Set up: sign up the user first + await descope_client.invoke(descope_client.password.sign_up(login_id, _PASSWORD, {"name": "Temp Pw Test"})) + + # Set a temporary password — must require replacement before use + await descope_client.invoke(descope_client.mgmt.user.set_temporary_password(login_id, "WASD+ijklll")) + + # Temporary password cannot be used directly for sign-in + with pytest.raises(AuthException): + await descope_client.invoke(descope_client.password.sign_in(login_id, "WASD+ijklll")) + + # Set an active password — immediately usable + await descope_client.invoke(descope_client.mgmt.user.set_active_password(login_id, "WASD+ijkqqq")) + + # Active password works for sign-in + jwt_response = await descope_client.invoke(descope_client.password.sign_in(login_id, "WASD+ijkqqq")) + assert jwt_response[SESSION_TOKEN_NAME]["jwt"], "Session token missing after sign-in with active password" + finally: + try: + await descope_client.invoke(descope_client.mgmt.user.delete(login_id)) + except Exception: + pass diff --git a/uv.lock b/uv.lock index 105c82d7c..cf3c99bad 100644 --- a/uv.lock +++ b/uv.lock @@ -662,6 +662,8 @@ flask = [ [package.dev-dependencies] dev = [ { name = "pre-commit" }, + { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ruff" }, ] tests = [ @@ -691,6 +693,7 @@ provides-extras = ["flask"] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = "==3.8.0" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "ruff", specifier = "==0.15.13" }, ] tests = [ @@ -1491,6 +1494,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +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/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"