Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5476528
feat: add e2e test suite ported from integrationtests
LioriE Jun 11, 2026
49a3356
fix(lint): add F841 to test ignores and fix E712 in test_flow
LioriE Jun 11, 2026
0006a3a
fix(lint): apply ruff format to all files
LioriE Jun 11, 2026
8707d7a
fix(tests): tolerate missing python-dotenv in e2e conftest
LioriE Jun 11, 2026
c9eec1f
fix(tests): fail-fast on missing e2e env vars instead of skipping
LioriE Jun 11, 2026
94c4690
fix(ci): exclude tests/e2e from build job's pytest run
LioriE Jun 11, 2026
5879ade
ci(e2e): rate-limit guards for prod e2e — renovate skip, concurrency …
LioriE Jun 15, 2026
68f1f56
ci(e2e): skip e2e on draft PRs
LioriE Jun 15, 2026
2cebe3a
ci(e2e): split into sandbox pre-flight + prod jobs
LioriE Jun 15, 2026
6cc7207
ci(e2e): restrict sandbox job to PRs only, prod runs unconditionally …
LioriE Jun 15, 2026
f53407d
ci(e2e): explicit event conditions on e2e-prod, decouple from sandbox…
LioriE Jun 15, 2026
6a73ed2
ci(e2e): rename e2e-fast label to quick-e2e
LioriE Jun 15, 2026
0b4b9fb
ci(e2e): drop prod job, run sandbox-only e2e on PRs + main + nightly
LioriE Jun 16, 2026
0f03505
Merge branch 'feat/poc-async-base-and-totp' into feat/e2e-tests
LioriE Jun 17, 2026
d5c1d7a
fix(tests): fix mock_post NameError in test_tenant — use as mock_post…
LioriE Jun 17, 2026
78b013d
format
LioriE Jun 17, 2026
09c5282
ci(e2e): reduce debounce from 5 min to 1 min
LioriE Jun 17, 2026
5f93c21
format
LioriE Jun 17, 2026
7253c0a
ci(e2e): add JUnit report via mikepenz/action-junit-report
LioriE Jun 17, 2026
58a8538
test(e2e): skip company-permission tests; fix password policy assertion
LioriE Jun 17, 2026
5d7d389
test(e2e): print password policy gaps so devs know what to configure
LioriE Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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!
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,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 (e.g. `http://localhost:8000` for a local cluster) |

**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
Expand Down
8 changes: 6 additions & 2 deletions descope/authmethod/webauthn.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def sign_up_finish(
body = self._compose_sign_up_in_finish_body(transaction_id, response)
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)
return self._auth.generate_jwt_response(
resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience
)

def sign_in_start(
self,
Expand Down Expand Up @@ -86,7 +88,9 @@ def sign_in_finish(
body = self._compose_sign_up_in_finish_body(transaction_id, response)
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)
return self._auth.generate_jwt_response(
resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience
)

def sign_up_or_in_start(
self,
Expand Down
8 changes: 6 additions & 2 deletions descope/authmethod/webauthn_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ async def sign_up_finish(
body = self._compose_sign_up_in_finish_body(transaction_id, response)
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)
return self._auth.generate_jwt_response(
resp, http_response.cookies.get(REFRESH_SESSION_COOKIE_NAME, None), audience
)

async def sign_in_start(
self,
Expand Down Expand Up @@ -81,7 +83,9 @@ async def sign_in_finish(
body = self._compose_sign_up_in_finish_body(transaction_id, response)
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)
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,
Expand Down
1 change: 0 additions & 1 deletion descope/management/access_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,3 @@ def delete(
MgmtV1.access_key_delete_path,
body={"id": id},
)

1 change: 0 additions & 1 deletion descope/management/access_key_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,3 @@ async def delete(
MgmtV1.access_key_delete_path,
body={"id": id},
)

1 change: 0 additions & 1 deletion descope/management/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,3 @@ def create_event(
body["data"] = data

self._http.post(MgmtV1.audit_create_event, body=body)

1 change: 0 additions & 1 deletion descope/management/audit_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,3 @@ async def create_event(
body["data"] = data

await self._http.post(MgmtV1.audit_create_event, body=body)

4 changes: 3 additions & 1 deletion descope/management/authz_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,9 @@ async def what_can_target_access(self, target: str) -> List[dict]:
)
return response.json()["relations"]

async def what_can_target_access_with_relation(self, target: str, relation_definition: str, namespace: str) -> List[dict]:
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:
Expand Down
1 change: 0 additions & 1 deletion descope/management/sso_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,4 +348,3 @@ def load_all(
"""
response = self._http.get(MgmtV1.sso_application_load_all_path)
return response.json()

1 change: 0 additions & 1 deletion descope/management/sso_application_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,4 +350,3 @@ async def load_all(
"""
response = await self._http.get(MgmtV1.sso_application_load_all_path)
return response.json()

1 change: 0 additions & 1 deletion descope/management/sso_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,3 @@ def mapping(
MgmtV1.sso_mapping_path,
body=SSOSettings._compose_mapping_body(tenant_id, role_mappings, attribute_mapping),
)

5 changes: 3 additions & 2 deletions descope/management/sso_settings_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ async def configure(
"""
await self._http.post(
MgmtV1.sso_settings_path,
body=SSOSettingsAsync._compose_configure_body(tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains),
body=SSOSettingsAsync._compose_configure_body(
tenant_id, idp_url, entity_id, idp_cert, redirect_url, domains
),
)

# DEPRECATED
Expand Down Expand Up @@ -268,4 +270,3 @@ async def mapping(
MgmtV1.sso_mapping_path,
body=SSOSettingsAsync._compose_mapping_body(tenant_id, role_mappings, attribute_mapping),
)

8 changes: 6 additions & 2 deletions descope/management/user_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,9 @@ async def update_picture(
)
return response.json()

async def update_custom_attribute(self, login_id: str, attribute_key: str, attribute_val: Union[str, int, bool]) -> dict:
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.

Expand Down Expand Up @@ -1683,7 +1685,9 @@ async def generate_enchanted_link_for_test_user(
)
return response.json()

async def generate_embedded_link(self, login_id: str, custom_claims: Optional[dict] = None, timeout: int = 0) -> str:
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
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/_unified.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 4 additions & 16 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import asyncio
import os
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Empty file added tests/e2e/__init__.py
Empty file.
Loading
Loading