diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e7e2eca..18fa5af4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,3 +55,25 @@ jobs: working-directory: packages/runtime-sdk run: | uv run --frozen pytest -v --color=yes tests + + django-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.14'] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Run django-cf tests + working-directory: packages/django-cf + run: | + uv run --frozen pytest -v --color=yes tests diff --git a/packages/django-cf/AGENTS.md b/packages/django-cf/AGENTS.md index a4d82a5b..7bb7891a 100644 --- a/packages/django-cf/AGENTS.md +++ b/packages/django-cf/AGENTS.md @@ -18,22 +18,14 @@ It is published to PyPI as `django-cf` and imported as `django_cf`. - Both database backends have transactions **disabled**. Every query commits immediately and rollbacks are unavailable, so code that relies on `atomic()` for correctness will not behave as it does on other backends. - This package still uses a flat layout (`django_cf/`) and a setuptools build backend, unlike `packages/cli` and `packages/runtime-sdk` which use src-layout and hatchling. - `templates/d1/` and `templates/durable-objects/` are deployable example projects, and `tests/servers/r2/` is a fixture app used by the R2 integration tests. None of them are part of the wheel. -- Ruff is configured in `pyproject.toml`. `target-version` is `py312` here because the package declares `requires-python = ">=3.12"`; the two sibling packages target `py311`. - That ruff config deliberately ignores `B904`, `B905`, `C901`, `PERF401`, `PLR0911`, `PLR0912`, `PLR0913`, `PLR0915`, `PLR2004`, `PLW0603` and `UP038`, because fixing the existing violations would mean behavioural or structural changes. Write new code that does not need those ignores. - mypy and semgrep currently skip this package. Adding either is a deliberate follow-up, not something to switch on incidentally. ## Testing - Lint everything with `uvx pre-commit run -a` from the repository root. -- The suites split by whether they need a real Worker: - - Host-runnable, no Node required: `tests/db/`, `tests/middleware/`, `tests/test_wsgi_handler.py`. - - Require `wrangler dev`: `tests/d1/`, `tests/durable_objects/`, `tests/r2/`, `tests/e2e/`, `tests/test_date_trunc.py`. -- A bare `pytest` collects everything and fails without a Node toolchain. For the host-runnable subset, run from `packages/django-cf`: - ```bash - uv sync - uv run pytest tests/db tests/middleware tests/test_wsgi_handler.py - ``` -- The Worker-backed suites additionally need `npm run setup-test`, which runs `npm install` and copies `django_cf/` into each fixture's `python_modules/` directory. Adding, moving or renaming library files means re-running it. -- Those suites get their base URL from the `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures in `tests/utils.py`, each of which spawns `npx wrangler dev` on a free port. -- The template and fixture apps expose management endpoints for test setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. -- This package has no test job in `.github/workflows/tests.yml` yet, so nothing here runs in CI. +- Every suite needs a Node toolchain, because every suite runs against a real Worker. Run them from `packages/django-cf` with `uv run --frozen pytest tests`; the `django-test` job in `.github/workflows/tests.yml` runs the same command. +- `tests/conftest.py` is the whole harness. It copies the worker project into a tmpdir, runs `pywrangler sync`, overwrites the vendored `django_cf/` with the working tree, then starts `pywrangler dev` on a free port. Nothing is installed into the repository, so there is no setup step to re-run after editing library files. +- Two shapes of suite: + - `tests/d1/`, `tests/durable_objects/`, `tests/r2/` drive a deployed Django app over HTTP, via the session-scoped `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures. Those apps live in `templates/` and `tests/servers/r2/` and expose management endpoints for setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. + - `tests/in_worker/` runs pytest *inside* workerd. The real test bodies are `tests/in_worker/worker/src/test_*.py`; `register_in_worker_suites` discovers them by AST and generates one host-side test per in-worker test, so a failure inside the Worker surfaces as an ordinary pytest failure. `pyproject.toml` ignores that `src` directory so the host collector does not try to import Worker-only modules. diff --git a/packages/django-cf/django_cf/db/backends/d1/base.py b/packages/django-cf/django_cf/db/backends/d1/base.py index 27d84084..a42fbf4a 100644 --- a/packages/django-cf/django_cf/db/backends/d1/base.py +++ b/packages/django-cf/django_cf/db/backends/d1/base.py @@ -83,17 +83,18 @@ def run_query(self, query, params=None) -> CFResult: read_only = is_read_only_query(proc_query) try: if read_only: - response = self.run_sync(stmt.raw()).to_py() + response = self.run_sync(stmt.raw()) result = CFResult.from_object(query, params, response, len(response), 0) else: response = self.run_sync(stmt.all()) + meta = response["meta"] result = CFResult.from_object( query, params, - response.results.to_py(), - response.meta.rows_read, - response.meta.rows_written, - response.meta.last_row_id, + response["results"], + meta["rows_read"], + meta["rows_written"], + meta["last_row_id"], ) except Exception: from js import Error diff --git a/packages/django-cf/django_cf/storage/r2.py b/packages/django-cf/django_cf/storage/r2.py index 855da017..17ec7d32 100644 --- a/packages/django-cf/django_cf/storage/r2.py +++ b/packages/django-cf/django_cf/storage/r2.py @@ -115,7 +115,7 @@ def _read(self, name): if r2_object is None: return None - return self._run_sync(r2_object.arrayBuffer()).to_bytes() + return bytes(self._run_sync(r2_object.arrayBuffer())) except Exception: return None @@ -174,9 +174,7 @@ def listdir(self, path): full_path += "/" bucket = self._get_bucket() - result = self._run_sync( - bucket.list({"prefix": full_path, "delimiter": "/"}) - ).to_py() + result = self._run_sync(bucket.list({"prefix": full_path, "delimiter": "/"})) directories = [] files = [] @@ -189,8 +187,6 @@ def listdir(self, path): objects = result.get("objects", []) for obj in objects: - _obj = obj.to_py() - if not obj.key.endswith("/"): files.append(os.path.basename(obj.key)) diff --git a/packages/django-cf/package.json b/packages/django-cf/package.json index 8dfca5a4..a6d666b6 100644 --- a/packages/django-cf/package.json +++ b/packages/django-cf/package.json @@ -12,11 +12,7 @@ }, "license": "MIT", "scripts": { - "setup-durable-objects": "cd templates/durable-objects && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", - "setup-d1": "cd templates/d1 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", - "setup-test-servers": "cd tests/servers/r2 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../../django_cf python_modules/django_cf", - "setup-test": "pip install -e .[dev] && npm run setup-durable-objects && npm run setup-d1 && npm run setup-test-servers", - "test": "pytest", + "test": "uv run pytest", "upgrade-templates": "cd templates/durable-objects && uv add django-cf --upgrade && cd ../d1 && uv add django-cf --upgrade", "build": "rm -rf dist/ && python3 -m build", "publish": "python3 -m twine upload dist/*" diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index 4dd38c36..c5b76274 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12" ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest", "pytest-cov", @@ -87,18 +87,14 @@ lint.ignore = [ "UP038", # `isinstance(x, X | Y)` (also ignored by runtime-sdk) ] lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments = true -# Pytest fixtures are imported for their side effect and then shadowed by the -# test function parameter of the same name, which reads as F811 to ruff. -lint.per-file-ignores."tests/**" = ["F811"] -# tests/utils.py launches `wrangler dev` in its own process group. -lint.per-file-ignores."tests/utils.py" = ["PLW1509"] # Generated Django scaffolding kept verbatim so it stays copy-pasteable. lint.per-file-ignores."tests/servers/**" = ["F401"] lint.per-file-ignores."templates/**" = ["F401"] [tool.pytest.ini_options] minversion = "6.0" -addopts = "-ra -q" +# TODO: e2e tests are flaky. Enable or refactor test cases +addopts = ["-ra", "--ignore=tests/e2e", "--ignore=tests/in_worker/worker/src"] testpaths = [ "tests", ] diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py new file mode 100644 index 00000000..02b7aff9 --- /dev/null +++ b/packages/django-cf/tests/conftest.py @@ -0,0 +1,399 @@ +"""Host-side fixtures that serve the test workers with ``pywrangler dev``. + +TODO: reduce the duplication between this file and packages/runtime-sdk/tests/conftest.py +""" + +# pyright: reportMissingImports=false, reportMissingModuleSource=false + +import ast +import contextlib +import functools +import os +import shutil +import signal +import socket +import subprocess +import time +from collections.abc import Callable, Generator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, TypedDict + +import pytest +import requests + +TEST_DIR: Path = Path(__file__).parent +PACKAGE_DIR: Path = TEST_DIR.parent +WORKERS_PY: Path = PACKAGE_DIR.parent / "cli" +WORKERS_RUNTIME_SDK: Path = PACKAGE_DIR.parent / "runtime-sdk" / "src" +DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf" + +D1_PROJECT: Path = PACKAGE_DIR / "templates" / "d1" +DURABLE_OBJECTS_PROJECT: Path = PACKAGE_DIR / "templates" / "durable-objects" +R2_PROJECT: Path = TEST_DIR / "servers" / "r2" +IN_WORKER_PROJECT: Path = TEST_DIR / "in_worker" / "worker" + +DEV_STARTUP_TIMEOUT: int = 240 +DEV_POLL_INTERVAL: float = 0.5 +SEED_TIMEOUT: int = 180 +TEARDOWN_TIMEOUT: int = 10 +SUITE_CONNECT_TIMEOUT: int = 10 +SUITE_READ_TIMEOUT: int = 300 + +GENERATED = shutil.ignore_patterns( + ".venv", + ".venv-workers", + ".wrangler", + "__pycache__", + "node_modules", + "python_modules", + "staticfiles", +) + + +@dataclass(frozen=True) +class CompatConfig: + compat_date: str + python_version: str + extra_compat_flags: list[str] = field(default_factory=list) + + +COMPAT_CONFIGS: list[CompatConfig] = [ + CompatConfig( + compat_date="2025-09-01", + python_version="3.12", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-01-01", + python_version="3.13", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-07-01", + python_version="3.14", + # TODO: remove these when 3.14 is stable, and enabled by date + extra_compat_flags=["python_workers_20260610", "experimental"], + ), +] + + +@dataclass(frozen=True) +class DevServer: + base_url: str + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _terminate(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + # `pywrangler dev` spawns npx -> wrangler -> workerd; signalling the group + # is the only way to avoid orphaning workerd. + group = os.getpgid(process.pid) + os.killpg(group, signal.SIGTERM) + try: + process.wait(timeout=TEARDOWN_TIMEOUT) + except subprocess.TimeoutExpired: + os.killpg(group, signal.SIGKILL) + process.wait() + + +def _fail(log_path: Path, message: str) -> None: + # Callers run inside `_dev_server`, whose finally block stops the worker. + pytest.fail( + f"{message}\n\n--- pywrangler dev log ---\n{log_path.read_text(errors='replace')}" + ) + + +def _wait_for_ready( + process: subprocess.Popen[bytes], base_url: str, log_path: Path +) -> None: + """Block until the worker answers. + + Any status counts: a 404 still proves workerd loaded the script and is + routing requests. + """ + deadline = time.monotonic() + DEV_STARTUP_TIMEOUT + while time.monotonic() < deadline: + if process.poll() is not None: + _fail( + log_path, f"pywrangler dev exited early with code {process.returncode}" + ) + try: + requests.get(base_url, timeout=5) + return + except requests.RequestException: + time.sleep(DEV_POLL_INTERVAL) + + _fail(log_path, f"pywrangler dev was not ready within {DEV_STARTUP_TIMEOUT}s") + + +@contextlib.contextmanager +def _dev_server( + target: Path, tmp_path: Path, env: dict[str, str], pywrangler: list[str] +) -> Generator[tuple[str, Path]]: + """Run `pywrangler dev` on a free port, yielding its base URL and log path.""" + port = get_free_port() + base_url = f"http://127.0.0.1:{port}" + log_path = tmp_path / f"{target.name}-dev.log" + + with log_path.open("w") as log_file: + process = subprocess.Popen( + [ + *pywrangler, + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=log_file, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + try: + _wait_for_ready(process, base_url, log_path) + yield base_url, log_path + finally: + _terminate(process) + + +def _seed(base_url: str, log_path: Path) -> None: + for endpoint in ("__run_migrations__", "__create_admin__"): + try: + response = requests.get(f"{base_url}/{endpoint}/", timeout=SEED_TIMEOUT) + except requests.RequestException as error: + _fail(log_path, f"GET /{endpoint}/ failed: {error}") + else: + if response.status_code != 200: + _fail( + log_path, + f"GET /{endpoint}/ returned {response.status_code}: {response.text[:2000]}", + ) + payload = response.json() + if payload.get("status") == "error": + _fail(log_path, f"GET /{endpoint}/ reported: {payload.get('message')}") + + +def _serve(project_dir: Path, tmp_path: Path) -> Generator[DevServer]: + target = tmp_path / project_dir.name + shutil.copytree(project_dir, target, ignore=GENERATED) + + pywrangler = ["uv", "run", "--with", str(WORKERS_PY), "pywrangler"] + env = os.environ | {"WORKERS_CI": "1"} + + sync = subprocess.run( + [*pywrangler, "sync"], + cwd=target, + env=env, + capture_output=True, + text=True, + check=False, + ) + if sync.returncode != 0: + pytest.fail( + f"pywrangler sync failed for {project_dir.name}\n{sync.stdout}\n{sync.stderr}" + ) + + # `sync` vendors the released django-cf from PyPI; tests must exercise the + # working tree instead. + vendored = target / "python_modules" / "django_cf" + shutil.rmtree(vendored, ignore_errors=True) + shutil.copytree( + DJANGO_CF_SRC, vendored, ignore=shutil.ignore_patterns("__pycache__") + ) + + with _dev_server(target, tmp_path, env, pywrangler) as (base_url, log_path): + _seed(base_url, log_path) + yield DevServer(base_url) + + +@pytest.fixture(scope="session") +def d1_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServer]: + yield from _serve(D1_PROJECT, tmp_path_factory.mktemp("d1")) + + +@pytest.fixture(scope="session") +def durable_objects_web_server( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[DevServer]: + yield from _serve( + DURABLE_OBJECTS_PROJECT, tmp_path_factory.mktemp("durable_objects") + ) + + +@pytest.fixture(scope="session") +def r2_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServer]: + yield from _serve(R2_PROJECT, tmp_path_factory.mktemp("r2")) + + +def replace_compat_date(file: Path, compat_date: str) -> None: + file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) + + +def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: + if not extra_flags: + return + content = file.read_text() + for flag in extra_flags: + content = content.replace('"python_workers"', f'"python_workers", "{flag}"') + file.write_text(content) + + +@pytest.fixture( + scope="module", + params=COMPAT_CONFIGS, + ids=[c.python_version for c in COMPAT_CONFIGS], +) +def compat_config(request: pytest.FixtureRequest) -> CompatConfig: + return request.param + + +@pytest.fixture(scope="module") +def in_worker_server( + tmp_path_factory: pytest.TempPathFactory, compat_config: CompatConfig +) -> Generator[str]: + """Serve ``tests/in_worker/worker``, once per compat config. + + Unlike the app fixtures above, this one runs ``uv run --no-project`` and + vendors the runtime SDK and django-cf working trees by hand: the worker has + no Django project to build, it only needs the two libraries importable. + """ + tmp_path = tmp_path_factory.mktemp("in_worker") + target = tmp_path / IN_WORKER_PROJECT.name + shutil.copytree(IN_WORKER_PROJECT, target, ignore=GENERATED) + + wrangler_jsonc = target / "wrangler.jsonc" + replace_compat_date(wrangler_jsonc, compat_config.compat_date) + inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) + + pywrangler = [ + "uv", + "run", + "--frozen", + "--no-project", + "--with", + str(WORKERS_PY), + "pywrangler", + ] + env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} + + subprocess.run([*pywrangler, "sync"], cwd=target, check=True, env=env) + + shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) + shutil.copytree( + DJANGO_CF_SRC, + target / "python_modules" / "django_cf", + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__"), + ) + + with _dev_server(target, tmp_path, env, pywrangler) as (base_url, _): + yield base_url + + +class InWorkerTestResult(TypedDict): + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str + + +SuiteResults = dict[str, InWorkerTestResult] + + +@functools.cache +def get_suite_results(in_worker_server: str, suite: str) -> SuiteResults | str: + try: + resp = requests.get( + f"{in_worker_server}/run-tests/{suite}", + timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), + ) + except requests.RequestException as error: + return f"Suite '{suite}' request failed: {error}" + if not resp.ok: + return f"Suite '{suite}' returned {resp.status_code}: {resp.text}" + return resp.json() + + +def _make_test(suite: str, test_name: str) -> Callable: + def test_fn(self: Any, in_worker_server: str) -> None: + results = get_suite_results(in_worker_server, suite) + if isinstance(results, str): + pytest.fail(results) + return + result: InWorkerTestResult | None = results.get(test_name) + assert result is not None, ( + f"Test {suite}::{test_name} not found in results; " + f"available keys: {sorted(results)}" + ) + if result["status"] == "skipped": + pytest.skip(result.get("reason", "")) + elif result["status"] == "failed": + pytest.fail(result["error"]) + elif result["status"] == "error": + pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") + + test_fn.__name__ = f"test_{test_name}" + return test_fn + + +def make_suite_class(suite: str, tests: list[str]) -> type: + camel = "".join(part.title() for part in suite.split("_")) + return type( + f"Test{camel}", + (), + {f"test_{name}": _make_test(suite, name) for name in tests}, + ) + + +def _normalize_test_name(*parts: str) -> str: + normalized = [] + for part in parts: + normalized.append(part[len("test_") :] if part.startswith("test_") else part) + return "__".join(normalized) + + +def discover_test_names(module_path: Path) -> list[str]: + tree = ast.parse(module_path.read_text()) + names = [] + for node in tree.body: + if isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef + ) and node.name.startswith("test_"): + names.append(_normalize_test_name(node.name)) + elif isinstance(node, ast.ClassDef): + for child in node.body: + if isinstance( + child, ast.FunctionDef | ast.AsyncFunctionDef + ) and child.name.startswith("test_"): + names.append(_normalize_test_name(node.name, child.name)) + return names + + +def discover_suites(src_dir: Path) -> dict[str, list[str]]: + return { + module_path.stem[len("test_") :]: discover_test_names(module_path) + for module_path in sorted(src_dir.glob("test_*.py")) + } + + +def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: + for suite, test_names in discover_suites(src_dir).items(): + suite_cls = make_suite_class(suite, test_names) + namespace[suite_cls.__name__] = suite_cls diff --git a/packages/django-cf/tests/d1/test_admin.py b/packages/django-cf/tests/d1/test_admin.py index 2927454c..805d1162 100644 --- a/packages/django-cf/tests/d1/test_admin.py +++ b/packages/django-cf/tests/d1/test_admin.py @@ -1,7 +1,5 @@ import requests -from ..utils import d1_web_server # noqa: F401 - def get_csrf_token(client, url): """Fetches a page and extracts the CSRF token from a form.""" diff --git a/packages/django-cf/tests/d1/test_worker.py b/packages/django-cf/tests/d1/test_worker.py index 95174902..7b079e18 100644 --- a/packages/django-cf/tests/d1/test_worker.py +++ b/packages/django-cf/tests/d1/test_worker.py @@ -1,7 +1,5 @@ import requests -from ..utils import d1_web_server # noqa: F401 - def test_migrations(d1_web_server): """Run migrations.""" diff --git a/packages/django-cf/tests/db/__init__.py b/packages/django-cf/tests/db/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/django-cf/tests/db/test_d1_backend.py b/packages/django-cf/tests/db/test_d1_backend.py deleted file mode 100644 index 5c109f76..00000000 --- a/packages/django-cf/tests/db/test_d1_backend.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Tests for django_cf/db/backends/d1/base.py - D1 database backend.""" - -import sys -from unittest.mock import MagicMock, patch - -import pytest - - -class TestD1DatabaseWrapperProcessQuery: - """Tests for D1 DatabaseWrapper.process_query method.""" - - def _create_mock_wrapper(self): - """Create a mock D1 DatabaseWrapper for testing.""" - # We need to mock the worker imports before importing the module - mock_workers = MagicMock() - mock_run_sync = MagicMock() - - with patch.dict( - sys.modules, - {"workers": mock_workers, "pyodide.ffi": MagicMock(run_sync=mock_run_sync)}, - ): - # Create a mock wrapper that mimics D1 DatabaseWrapper - class MockD1Wrapper: - def __init__(self): - self.run_sync = mock_run_sync - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = False - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - # Import the actual function we want to test - from django_cf.db.base_engine import replace_date_trunc_in_sql - - # Replace date trunc - query = replace_date_trunc_in_sql(query) - - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - return MockD1Wrapper() - - def test_process_query_no_params(self): - """Test process_query replaces %s with ? when no params.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s" - result_query, result_params = wrapper.process_query(query, None) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - """Test process_query replaces %s with ? for each param.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s AND name = %s" - params = [1, "test"] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - """Test process_query replaces None params with literal 'null'.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = ["test", None] - result_query, result_params = wrapper.process_query(query, params) - - # None should be replaced with literal 'null', not ? - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_all_null_params(self): - """Test process_query when all params are None.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = [None, None] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" - assert result_params == [] - - def test_process_query_mixed_params(self): - """Test process_query with mixed None and non-None params.""" - wrapper = self._create_mock_wrapper() - - query = "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s" - params = ["test", None, 25, None] - result_query, result_params = wrapper.process_query(query, params) - - assert result_params == ["test", 25] - # Count ? marks - should be 2 (for 'test' and 25) - assert result_query.count("?") == 2 - # Count null literals - should be 2 - assert result_query.count("null") == 2 - - def test_process_query_with_defer_foreign_keys(self): - """Test process_query adds PRAGMA when _defer_foreign_keys is True.""" - wrapper = self._create_mock_wrapper() - wrapper._cursor_mock._defer_foreign_keys = True - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - result = wrapper.process_query(query, params) - - # When defer_foreign_keys is True, returns string (not tuple) - # This is actually a bug in the implementation - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - def test_process_query_date_trunc_replacement(self): - """Test process_query replaces django_date_trunc calls.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - params = ["year", "UTC", "UTC"] - result_query, result_params = wrapper.process_query(query, params) - - # Date trunc should be replaced - assert "django_date_trunc" not in result_query - assert "CASE" in result_query or "STRFTIME" in result_query - - -class TestD1DatabaseWrapperConfiguration: - """Tests for D1 DatabaseWrapper configuration.""" - - def test_vendor_name(self): - """Test vendor name is correct.""" - # We can't fully import without worker environment, - # but we can check the class definition - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - assert spec is not None - - def test_display_name(self): - """Test display name is 'D1'.""" - # Read the source to verify the display_name - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - with open(spec.origin) as f: - content = f.read() - assert 'display_name = "D1"' in content - assert 'vendor = "cloudflare_d1"' in content - - -class TestD1GetConnectionParams: - """Tests for D1 get_connection_params method.""" - - def test_missing_binding_raises_error(self): - """Test that missing CLOUDFLARE_BINDING raises ImproperlyConfigured.""" - from django.core.exceptions import ImproperlyConfigured - - # Create minimal mock to test get_connection_params logic - class MockWrapper: - settings_dict = {"CLOUDFLARE_BINDING": None} - - def get_connection_params(self): - if not self.settings_dict["CLOUDFLARE_BINDING"]: - raise ImproperlyConfigured( - "settings.DATABASES is improperly configured. " - "Please supply the CLOUDFLARE_BINDING value." - ) - return {"binding": self.settings_dict["CLOUDFLARE_BINDING"]} - - wrapper = MockWrapper() - with pytest.raises(ImproperlyConfigured) as exc_info: - wrapper.get_connection_params() - - assert "CLOUDFLARE_BINDING" in str(exc_info.value) - - def test_valid_binding_returns_params(self): - """Test that valid CLOUDFLARE_BINDING returns correct params.""" - - class MockWrapper: - settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} - - def get_connection_params(self): - if not self.settings_dict["CLOUDFLARE_BINDING"]: - from django.core.exceptions import ImproperlyConfigured - - raise ImproperlyConfigured( - "settings.DATABASES is improperly configured. " - "Please supply the CLOUDFLARE_BINDING value." - ) - return {"binding": self.settings_dict["CLOUDFLARE_BINDING"]} - - wrapper = MockWrapper() - params = wrapper.get_connection_params() - - assert params == {"binding": "MY_DB"} - - -class TestD1ExceptionHandling: - """Tests for D1 backend exception handling.""" - - def test_run_query_uses_except_exception(self): - """Test that run_query uses 'except Exception' instead of bare 'except'. - - Bare except clauses catch BaseException subclasses like KeyboardInterrupt - and SystemExit, which should be allowed to propagate normally. - """ - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - with open(spec.origin) as f: - content = f.read() - # Should use 'except Exception:' not bare 'except:' - assert "except Exception:" in content - # Should NOT have bare except (except inside 'except Exception') - lines = content.split("\n") - for line in lines: - stripped = line.strip() - if stripped.startswith("except") and stripped.endswith(":"): - assert stripped != "except:", f"Found bare 'except:' clause: {line}" - - -class TestD1ParameterHandling: - """Tests for D1 parameter type handling edge cases.""" - - def test_empty_params_list(self): - """Test handling of empty params list.""" - - # Create minimal process_query implementation - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "SELECT * FROM users" - result_query, result_params = process_query(query, []) - - assert result_query == "SELECT * FROM users" - assert result_params == [] - - def test_special_characters_in_params(self): - """Test handling of special characters in parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test'; DROP TABLE users; --"] - result_query, result_params = process_query(query, params) - - # The dangerous string should be kept as a parameter, not interpolated - assert result_params == ["test'; DROP TABLE users; --"] - assert "?" in result_query - - def test_unicode_params(self): - """Test handling of unicode parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO users (name) VALUES (%s)" - params = [""] - result_query, result_params = process_query(query, params) - - assert result_params == [""] - - def test_large_number_of_params(self): - """Test handling of many parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - placeholders = ", ".join(["%s"] * 50) - query = f"INSERT INTO test VALUES ({placeholders})" - params = list(range(50)) - result_query, result_params = process_query(query, params) - - assert result_query.count("?") == 50 - assert len(result_params) == 50 diff --git a/packages/django-cf/tests/db/test_do_backend.py b/packages/django-cf/tests/db/test_do_backend.py deleted file mode 100644 index 4b943842..00000000 --- a/packages/django-cf/tests/db/test_do_backend.py +++ /dev/null @@ -1,382 +0,0 @@ -"""Tests for django_cf/db/backends/do/base.py - Durable Objects database backend.""" - -from unittest.mock import MagicMock - - -class TestDODatabaseWrapperProcessQuery: - """Tests for DO DatabaseWrapper.process_query method.""" - - def _create_mock_wrapper(self): - """Create a mock DO DatabaseWrapper for testing.""" - - class MockDOWrapper: - def __init__(self): - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = False - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - return MockDOWrapper() - - def test_process_query_no_params(self): - """Test process_query replaces %s with ? when no params.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s" - result_query, result_params = wrapper.process_query(query, None) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - """Test process_query replaces %s with ? for each param.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s AND name = %s" - params = [1, "test"] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - """Test process_query replaces None params with literal 'null'.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = ["test", None] - result_query, result_params = wrapper.process_query(query, params) - - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_with_defer_foreign_keys(self): - """Test process_query adds PRAGMA when _defer_foreign_keys is True.""" - wrapper = self._create_mock_wrapper() - wrapper._cursor_mock._defer_foreign_keys = True - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - result = wrapper.process_query(query, params) - - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - -class TestDODatabaseWrapperConfiguration: - """Tests for DO DatabaseWrapper configuration.""" - - def test_vendor_name(self): - """Test vendor name is correct.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - assert 'vendor = "cloudflare_durable_objects"' in content - assert 'display_name = "DO"' in content - - def test_get_connection_params_returns_empty(self): - """Test that get_connection_params returns empty dict for DO.""" - - # DO backend doesn't need connection params like D1 does - class MockDOWrapper: - def get_connection_params(self): - return {} - - wrapper = MockDOWrapper() - params = wrapper.get_connection_params() - - assert params == {} - - -class TestDOMissingDateTruncBug: - """Tests documenting the missing date_trunc bug in DO backend.""" - - def test_do_process_query_missing_date_trunc(self): - """ - Test demonstrating that DO backend is missing date_trunc replacement. - - The D1 backend calls replace_date_trunc_in_sql() in process_query(), - but the DO backend does NOT. This is a bug that should be fixed. - """ - - # DO backend's process_query (simulated - without date_trunc) - def do_process_query(query, params=None): - # Note: NO replace_date_trunc_in_sql call - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - # D1 backend's process_query (simulated - with date_trunc) - def d1_process_query(query, params=None): - from django_cf.db.base_engine import replace_date_trunc_in_sql - - query = replace_date_trunc_in_sql(query) - - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - test_query = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - test_params = ["year", "UTC", "UTC"] - - # D1 correctly replaces date_trunc - d1_result, _ = d1_process_query(test_query, test_params) - assert "django_date_trunc" not in d1_result - - # DO does NOT replace date_trunc (BUG) - do_result, _ = do_process_query(test_query, test_params) - # This shows the bug - django_date_trunc is still in the query - # When this test fails after fix, remove the bug documentation - assert "django_date_trunc" in do_result # Bug: should NOT be in result - - -class TestDOStorageInitialization: - """Tests for DO storage initialization.""" - - def test_storage_module_exists(self): - """Test that storage module exists for DO backend.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.storage") - assert spec is not None - - def test_get_storage_function_exists(self): - """Test that get_storage function exists in storage module.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - assert "from .storage import get_storage" in content - - -class TestDOQueryExecution: - """Tests for DO query execution patterns.""" - - def test_read_query_uses_raw(self): - """Test that read queries use raw() method.""" - - # Simulated DO run_query logic - def mock_run_query(query, params=None, is_read=True): - mock_db = MagicMock() - mock_stmt = MagicMock() - - if params: - mock_db.exec.return_value = mock_stmt - else: - mock_db.exec.return_value = mock_stmt - - if is_read: - # Read queries call raw().toArray() - mock_stmt.raw.return_value.toArray.return_value.to_py.return_value = [] - return mock_stmt.raw().toArray().to_py() - else: - return mock_stmt - - result = mock_run_query("SELECT * FROM users", is_read=True) - assert result == [] - - def test_error_handling_exposes_stack(self): - """ - Test documenting that DO backend exposes full stack traces. - - This is a potential security issue - stack traces should not - be exposed in production errors. - """ - # The DO backend has this pattern: - # except: - # from js import Error - # Error.stackTraceLimit = 1e10 - # raise Error(Error.new().stack) - - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - # Verify the problematic pattern exists - assert "Error.stackTraceLimit = 1e10" in content - assert "raise Error(Error.new().stack)" in content - - -class TestDOExceptionHandling: - """Tests for DO backend exception handling.""" - - def test_run_query_uses_except_exception(self): - """Test that run_query uses 'except Exception' instead of bare 'except'. - - Bare except clauses catch BaseException subclasses like KeyboardInterrupt - and SystemExit, which should be allowed to propagate normally. - """ - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - # Should use 'except Exception:' not bare 'except:' - assert "except Exception:" in content - # Should NOT have bare except - lines = content.split("\n") - for line in lines: - stripped = line.strip() - if stripped.startswith("except") and stripped.endswith(":"): - assert stripped != "except:", f"Found bare 'except:' clause: {line}" - - -class TestDOParamConversion: - """Tests for DO parameter type conversion.""" - - def test_boolean_true_not_converted_in_process_query(self): - """ - Test that boolean True is NOT converted in process_query. - - Note: Boolean conversion happens in CFDatabase.execute(), not process_query(). - """ - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO test (active) VALUES (%s)" - params = [True] - result_query, result_params = process_query(query, params) - - # process_query doesn't convert booleans - that's CFDatabase.execute's job - assert result_params == [True] - - def test_multiple_none_params_order_preserved(self): - """Test that order is preserved with multiple None params.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)" - params = [1, None, 2, None] - result_query, result_params = process_query(query, params) - - # Non-None params should be in order - assert result_params == [1, 2] - # Query should have correct mix of ? and null - assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" - - -class TestDOPragmaReturnTypeBug: - """Tests documenting the PRAGMA return type bug in DO backend.""" - - def test_pragma_returns_string_not_tuple(self): - """ - Test demonstrating that PRAGMA mode returns string instead of tuple. - - When _defer_foreign_keys is True, process_query returns a string - instead of a (query, params) tuple. This causes the params to be lost. - """ - - class MockDOWrapper: - def __init__(self): - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = True - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - # BUG: Returns string, not tuple - params are lost! - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - wrapper = MockDOWrapper() - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - - result = wrapper.process_query(query, params) - - # Bug: result is a string, not a tuple - assert isinstance(result, str) - # The params ['test'] are completely lost! - # This will cause issues when trying to unpack: proc_query, params = result diff --git a/packages/django-cf/tests/durable_objects/test_admin.py b/packages/django-cf/tests/durable_objects/test_admin.py index 69cd3c0c..a3a5be4b 100644 --- a/packages/django-cf/tests/durable_objects/test_admin.py +++ b/packages/django-cf/tests/durable_objects/test_admin.py @@ -1,7 +1,5 @@ import requests -from ..utils import durable_objects_web_server # noqa: F401 - def get_csrf_token(client, url): """Fetches a page and extracts the CSRF token from a form.""" diff --git a/packages/django-cf/tests/durable_objects/test_worker.py b/packages/django-cf/tests/durable_objects/test_worker.py index ea03665c..fe001b47 100644 --- a/packages/django-cf/tests/durable_objects/test_worker.py +++ b/packages/django-cf/tests/durable_objects/test_worker.py @@ -1,7 +1,5 @@ import requests -from ..utils import durable_objects_web_server # noqa: F401 - def test_migrations(durable_objects_web_server): """Run migrations.""" diff --git a/packages/django-cf/tests/e2e/test_workflows.py b/packages/django-cf/tests/e2e/test_workflows.py index 6c8f0bb5..51821260 100644 --- a/packages/django-cf/tests/e2e/test_workflows.py +++ b/packages/django-cf/tests/e2e/test_workflows.py @@ -7,13 +7,6 @@ import pytest import requests -# Import fixtures from utils -from ..utils import ( # noqa: F401 - d1_web_server, - durable_objects_web_server, - r2_web_server, -) - class TestD1CRUDWorkflow: """Test complete CRUD workflow with D1 backend.""" diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py new file mode 100644 index 00000000..fde74dcb --- /dev/null +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -0,0 +1,62 @@ +"""Mirrors of the pytest suites that run inside workerd, plus WSGI round-trips. + +Python 3.12 (Pyodide 0.26.0a2) is excluded from the matrix. That runtime has no +JSPI, so ``pyodide.ffi.run_sync`` is missing and every R2Storage test skips +itself; what remains duplicates the 3.13 and 3.14 runs at a third of the +suite's wall clock. +""" + +# pyright: reportMissingImports=false, reportMissingModuleSource=false + +from pathlib import Path + +import pytest +import requests + +from tests.conftest import COMPAT_CONFIGS, CompatConfig, register_in_worker_suites + +IN_WORKER_SRC_DIR: Path = Path(__file__).parent / "worker" / "src" + +MATRIX: list[CompatConfig] = [c for c in COMPAT_CONFIGS if c.python_version != "3.12"] + + +@pytest.fixture( + scope="module", + params=MATRIX, + ids=[c.python_version for c in MATRIX], +) +def compat_config(request: pytest.FixtureRequest) -> CompatConfig: + return request.param + + +register_in_worker_suites(globals(), IN_WORKER_SRC_DIR) + + +def test_wsgi_header_transformation(in_worker_server: str) -> None: + response = requests.get( + f"{in_worker_server}/wsgi/headers", + headers={ + "cf-access-jwt-assertion": "jwt-token", + "x-custom-header": "custom-value", + "content-type": "text/plain", + }, + timeout=10, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["cf_access"] == "jwt-token" + assert payload["custom"] == "custom-value" + assert payload["content_type"] == "text/plain" + + +def test_wsgi_reads_request_body(in_worker_server: str) -> None: + response = requests.post( + f"{in_worker_server}/wsgi/body", + headers={"content-type": "text/plain"}, + data=b"request-body", + timeout=10, + ) + + assert response.status_code == 200 + assert response.text == "request-body" diff --git a/packages/django-cf/tests/in_worker/worker/pyproject.toml b/packages/django-cf/tests/in_worker/worker/pyproject.toml new file mode 100644 index 00000000..21f53690 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "django-cf-host-only-tests" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "django<6.1", + "pytest", + "sqlparse", +] diff --git a/packages/django-cf/tests/in_worker/worker/src/conftest.py b/packages/django-cf/tests/in_worker/worker/src/conftest.py new file mode 100644 index 00000000..8f18c4eb --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/conftest.py @@ -0,0 +1,15 @@ +# pyright: reportMissingImports=false + +import pytest +from django.core.cache import cache +from workers import env as _env + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + + +@pytest.fixture +def env(): + return _env diff --git a/packages/django-cf/tests/db/test_base_engine.py b/packages/django-cf/tests/in_worker/worker/src/test_base_engine.py similarity index 60% rename from packages/django-cf/tests/db/test_base_engine.py rename to packages/django-cf/tests/in_worker/worker/src/test_base_engine.py index c668833a..f6793e83 100644 --- a/packages/django-cf/tests/db/test_base_engine.py +++ b/packages/django-cf/tests/in_worker/worker/src/test_base_engine.py @@ -1,4 +1,6 @@ -"""Tests for django_cf/db/base_engine.py - Core database functionality.""" +"""Base engine tests executed inside workerd.""" + +# pyright: reportMissingImports=false from decimal import Decimal from unittest.mock import MagicMock, patch @@ -7,10 +9,7 @@ class TestCFResult: - """Tests for the CFResult class.""" - def test_init(self): - """Test CFResult initialization.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b"), (3, "c")] @@ -21,17 +20,14 @@ def test_init(self): assert result.rowcount == -1 def test_iter(self): - """Test CFResult iteration.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b")] result = CFResult(data) - items = list(result) - assert items == [(1, "a"), (2, "b")] + assert list(result) == [(1, "a"), (2, "b")] def test_set_lastrowid(self): - """Test setting lastrowid.""" from django_cf.db.base_engine import CFResult result = CFResult([]) @@ -40,7 +36,6 @@ def test_set_lastrowid(self): assert result.lastrowid == 42 def test_set_rowcount(self): - """Test setting rowcount.""" from django_cf.db.base_engine import CFResult result = CFResult([]) @@ -49,7 +44,6 @@ def test_set_rowcount(self): assert result.rowcount == 10 def test_fetchone_with_data(self): - """Test fetchone returns and removes last item.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b"), (3, "c")] @@ -60,48 +54,36 @@ def test_fetchone_with_data(self): assert len(result.data) == 2 def test_fetchone_empty(self): - """Test fetchone returns None when empty.""" from django_cf.db.base_engine import CFResult result = CFResult([]) - row = result.fetchone() - assert row is None + assert result.fetchone() is None def test_fetchall(self): - """Test fetchall returns all rows.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b"), (3, "c")] result = CFResult(data) - rows = result.fetchall() - # Note: fetchall uses fetchone which pops from end, so order is reversed - assert rows == [(3, "c"), (2, "b"), (1, "a")] + assert result.fetchall() == [(3, "c"), (2, "b"), (1, "a")] assert len(result.data) == 0 def test_fetchall_empty(self): - """Test fetchall on empty result.""" from django_cf.db.base_engine import CFResult - result = CFResult([]) - rows = result.fetchall() - - assert rows == [] + assert CFResult([]).fetchall() == [] def test_fetchmany_default(self): - """Test fetchmany with default size=1.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b"), (3, "c")] result = CFResult(data) - rows = result.fetchmany() - assert rows == [(3, "c")] + assert result.fetchmany() == [(3, "c")] assert len(result.data) == 2 def test_fetchmany_specific_size(self): - """Test fetchmany with specific size.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b"), (3, "c")] @@ -112,7 +94,6 @@ def test_fetchmany_specific_size(self): assert len(result.data) == 1 def test_fetchmany_more_than_available(self): - """Test fetchmany when requesting more than available.""" from django_cf.db.base_engine import CFResult data = [(1, "a"), (2, "b")] @@ -123,29 +104,22 @@ def test_fetchmany_more_than_available(self): assert len(result.data) == 0 def test_from_object_with_list_rows(self): - """Test from_object with list-style row data.""" from django_cf.db.base_engine import CFResult data = [[1, "hello", True], [2, "world", False]] result = CFResult.from_object("SELECT * FROM test", None, data) - rows = list(result) - assert len(rows) == 2 - assert rows[0] == (1, "hello", True) - assert rows[1] == (2, "world", False) + assert list(result) == [(1, "hello", True), (2, "world", False)] def test_from_object_with_dict_rows(self): - """Test from_object with dict-style row data.""" from django_cf.db.base_engine import CFResult data = [{"id": 1, "name": "hello"}, {"id": 2, "name": "world"}] result = CFResult.from_object("SELECT * FROM test", None, data) - rows = list(result) - assert len(rows) == 2 + assert len(list(result)) == 2 def test_from_object_insert_rowcount(self): - """Test from_object sets rowcount for INSERT.""" from django_cf.db.base_engine import CFResult result = CFResult.from_object( @@ -155,7 +129,6 @@ def test_from_object_insert_rowcount(self): assert result.rowcount == 5 def test_from_object_update_rowcount(self): - """Test from_object sets rowcount for UPDATE.""" from django_cf.db.base_engine import CFResult result = CFResult.from_object( @@ -165,7 +138,6 @@ def test_from_object_update_rowcount(self): assert result.rowcount == 3 def test_from_object_delete_rowcount(self): - """Test from_object sets rowcount for DELETE.""" from django_cf.db.base_engine import CFResult result = CFResult.from_object( @@ -175,21 +147,15 @@ def test_from_object_delete_rowcount(self): assert result.rowcount == 2 def test_from_object_select_rowcount(self): - """Test from_object sets rowcount for SELECT.""" from django_cf.db.base_engine import CFResult result = CFResult.from_object( - "SELECT * FROM test", - None, - [[1], [2], [3]], # List-style rows, not tuples - rows_read=3, - rows_written=0, + "SELECT * FROM test", None, [[1], [2], [3]], rows_read=3, rows_written=0 ) assert result.rowcount == 3 def test_from_object_lastrowid(self): - """Test from_object sets lastrowid.""" from django_cf.db.base_engine import CFResult result = CFResult.from_object( @@ -200,10 +166,7 @@ def test_from_object_lastrowid(self): class TestIsReadOnlyQuery: - """Tests for the is_read_only_query function.""" - def test_select_query(self): - """Test SELECT queries are read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("SELECT * FROM users") is True @@ -211,43 +174,36 @@ def test_select_query(self): assert is_read_only_query("select * from users") is True def test_insert_query(self): - """Test INSERT queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query('INSERT INTO users (name) VALUES ("test")') is False def test_update_query(self): - """Test UPDATE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query('UPDATE users SET name = "new" WHERE id = 1') is False def test_delete_query(self): - """Test DELETE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("DELETE FROM users WHERE id = 1") is False def test_create_table_query(self): - """Test CREATE TABLE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("CREATE TABLE users (id INT)") is False def test_alter_table_query(self): - """Test ALTER TABLE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("ALTER TABLE users ADD COLUMN email TEXT") is False def test_drop_table_query(self): - """Test DROP TABLE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("DROP TABLE users") is False def test_replace_query(self): - """Test REPLACE queries are not read-only.""" from django_cf.db.base_engine import is_read_only_query assert ( @@ -256,7 +212,6 @@ def test_replace_query(self): ) def test_empty_query(self): - """Test empty query returns False.""" from django_cf.db.base_engine import is_read_only_query assert is_read_only_query("") is False @@ -264,72 +219,64 @@ def test_empty_query(self): class TestReplaceDateTruncInSql: - """Tests for the replace_date_trunc_in_sql function.""" - def test_no_date_trunc(self): - """Test SQL without date_trunc is unchanged.""" from django_cf.db.base_engine import replace_date_trunc_in_sql sql = 'SELECT * FROM users WHERE created_at > "2023-01-01"' - result = replace_date_trunc_in_sql(sql) - - assert result == sql + assert replace_date_trunc_in_sql(sql) == sql def test_django_date_trunc_replacement(self): - """Test django_date_trunc is replaced with CASE statement.""" from django_cf.db.base_engine import replace_date_trunc_in_sql - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) - # Should contain CASE statement with STRFTIME assert "CASE %s" in result assert "STRFTIME" in result assert "django_date_trunc" not in result def test_django_datetime_trunc_replacement(self): - """Test django_datetime_trunc is replaced with CASE statement.""" from django_cf.db.base_engine import replace_date_trunc_in_sql - sql = "SELECT django_datetime_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) + result = replace_date_trunc_in_sql( + "SELECT django_datetime_trunc(%s, created_at, %s, %s) FROM orders" + ) - # Should contain CASE statement assert "CASE %s" in result assert "django_datetime_trunc" not in result def test_year_truncation_in_case(self): - """Test year truncation template is in result.""" from django_cf.db.base_engine import replace_date_trunc_in_sql - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) assert "WHEN 'year'" in result assert "%Y-01-01" in result def test_month_truncation_in_case(self): - """Test month truncation template is in result.""" from django_cf.db.base_engine import replace_date_trunc_in_sql - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) assert "WHEN 'month'" in result assert "%Y-%m-01" in result def test_day_truncation_in_case(self): - """Test day truncation template is in result.""" from django_cf.db.base_engine import replace_date_trunc_in_sql - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) assert "WHEN 'day'" in result assert "DATE(created_at)" in result def test_multiple_date_truncs(self): - """Test multiple django_date_trunc calls are replaced.""" from django_cf.db.base_engine import replace_date_trunc_in_sql sql = """SELECT django_date_trunc(%s, created_at, %s, %s), @@ -337,362 +284,261 @@ def test_multiple_date_truncs(self): result = replace_date_trunc_in_sql(sql) assert "django_date_trunc" not in result - # Should have multiple CASE statements assert result.count("CASE %s") == 2 class TestCFDatabase: - """Tests for the CFDatabase class.""" - def test_connect(self): - """Test CFDatabase.connect creates new instance.""" from django_cf.db.base_engine import CFDatabase mock_wrapper = MagicMock() db = CFDatabase.connect(mock_wrapper) - assert isinstance(db, CFDatabase) assert db.databaseWrapper == mock_wrapper def test_cursor_returns_self(self): - """Test cursor() returns self.""" from django_cf.db.base_engine import CFDatabase db = CFDatabase(MagicMock()) - cursor = db.cursor() - - assert cursor is db + assert db.cursor() is db def test_commit_does_nothing(self): - """Test commit() returns None (no-op).""" from django_cf.db.base_engine import CFDatabase - db = CFDatabase(MagicMock()) - result = db.commit() - - assert result is None + assert CFDatabase(MagicMock()).commit() is None def test_rollback_does_nothing(self): - """Test rollback() returns None (no-op).""" from django_cf.db.base_engine import CFDatabase - db = CFDatabase(MagicMock()) - result = db.rollback() - - assert result is None + assert CFDatabase(MagicMock()).rollback() is None def test_close_does_nothing(self): - """Test close() returns None.""" from django_cf.db.base_engine import CFDatabase - db = CFDatabase(MagicMock()) - result = db.close() - - assert result is None + assert CFDatabase(MagicMock()).close() is None def test_defer_foreign_keys(self): - """Test defer_foreign_keys correctly sets the instance variable.""" from django_cf.db.base_engine import CFDatabase db = CFDatabase(MagicMock()) - db.defer_foreign_keys(True) assert db._defer_foreign_keys is True - db.defer_foreign_keys(False) assert db._defer_foreign_keys is False def test_execute_converts_boolean_true(self): - """Test execute converts True to 1.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_wrapper.run_query.return_value = CFResult([]) - db = CFDatabase(mock_wrapper) + db.execute("INSERT INTO test VALUES (%s)", (True,)) - # Check that True was converted to 1 - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == (1,) + assert mock_wrapper.run_query.call_args[0][1] == (1,) def test_execute_converts_boolean_false(self): - """Test execute converts False to 0.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_wrapper.run_query.return_value = CFResult([]) - db = CFDatabase(mock_wrapper) + db.execute("INSERT INTO test VALUES (%s)", (False,)) - # Check that False was converted to 0 - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == (0,) + assert mock_wrapper.run_query.call_args[0][1] == (0,) def test_execute_converts_decimal_to_string(self): - """Test execute converts Decimal to string.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_wrapper.run_query.return_value = CFResult([]) - db = CFDatabase(mock_wrapper) + db.execute("INSERT INTO test VALUES (%s)", (Decimal("10.5"),)) - # Check that Decimal was converted to string - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == ("10.5",) + assert mock_wrapper.run_query.call_args[0][1] == ("10.5",) def test_execute_no_params(self): - """Test execute with no parameters.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_wrapper.run_query.return_value = CFResult([]) - db = CFDatabase(mock_wrapper) + db.execute("SELECT * FROM test") - call_args = mock_wrapper.run_query.call_args - assert call_args[0][0] == "SELECT * FROM test" - assert call_args[0][1] is None + assert mock_wrapper.run_query.call_args[0] == ("SELECT * FROM test", None) def test_fetchone_delegates_to_result(self): - """Test fetchone delegates to lastResult.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() - mock_result = CFResult([(1, "test")]) - mock_wrapper.run_query.return_value = mock_result - + mock_wrapper.run_query.return_value = CFResult([(1, "test")]) db = CFDatabase(mock_wrapper) + db.execute("SELECT * FROM test") - row = db.fetchone() - assert row == (1, "test") + assert db.fetchone() == (1, "test") def test_fetchall_delegates_to_result(self): - """Test fetchall delegates to lastResult.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() - mock_result = CFResult([(1, "a"), (2, "b")]) - mock_wrapper.run_query.return_value = mock_result - + mock_wrapper.run_query.return_value = CFResult([(1, "a"), (2, "b")]) db = CFDatabase(mock_wrapper) + db.execute("SELECT * FROM test") - rows = db.fetchall() - assert len(rows) == 2 + assert len(db.fetchall()) == 2 def test_lastrowid_property(self): - """Test lastrowid property delegates to lastResult.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_result = CFResult([]) mock_result.set_lastrowid(42) mock_wrapper.run_query.return_value = mock_result - db = CFDatabase(mock_wrapper) + db.execute("INSERT INTO test VALUES (1)") assert db.lastrowid == 42 def test_rowcount_property(self): - """Test rowcount property delegates to lastResult.""" from django_cf.db.base_engine import CFDatabase, CFResult mock_wrapper = MagicMock() mock_result = CFResult([]) mock_result.set_rowcount(5) mock_wrapper.run_query.return_value = mock_result - db = CFDatabase(mock_wrapper) + db.execute('UPDATE test SET name = "new"') assert db.rowcount == 5 class TestCFDatabaseFeatures: - """Tests for the CFDatabaseFeatures class.""" - def test_transactions_disabled(self): - """Test that transactions are disabled.""" from django_cf.db.base_engine import CFDatabaseFeatures - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) + features = CFDatabaseFeatures(MagicMock()) assert features.atomic_transactions is False assert features.supports_transactions is False def test_savepoints_disabled(self): - """Test that savepoints are disabled.""" from django_cf.db.base_engine import CFDatabaseFeatures - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.can_release_savepoints is False + assert CFDatabaseFeatures(MagicMock()).can_release_savepoints is False def test_constraint_checks_disabled(self): - """Test that constraint checks cannot be deferred.""" from django_cf.db.base_engine import CFDatabaseFeatures - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) + features = CFDatabaseFeatures(MagicMock()) assert features.can_defer_constraint_checks is False assert features.supports_pragma_foreign_key_check is False def test_max_query_params(self): - """Test max_query_params is set.""" from django_cf.db.base_engine import CFDatabaseFeatures - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.max_query_params == 100 + assert CFDatabaseFeatures(MagicMock()).max_query_params == 100 def test_bulk_insert_enabled(self): - """Test bulk insert is enabled.""" from django_cf.db.base_engine import CFDatabaseFeatures - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) + features = CFDatabaseFeatures(MagicMock()) assert features.has_bulk_insert is True assert features.can_return_columns_from_insert is True class TestCFDatabaseWrapper: - """Tests for the CFDatabaseWrapper class.""" - def test_get_database_version(self): - """Test get_database_version returns tuple.""" from django_cf.db.base_engine import CFDatabaseWrapper - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - version = wrapper.get_database_version() - - assert version == (4,) + assert wrapper.get_database_version() == (4,) def test_close_does_nothing(self): - """Test close() is a no-op.""" from django_cf.db.base_engine import CFDatabaseWrapper - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper.close() - - assert result is None + assert wrapper.close() is None def test_savepoint_not_allowed(self): - """Test _savepoint_allowed returns False.""" from django_cf.db.base_engine import CFDatabaseWrapper - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper._savepoint_allowed() - - assert result is False + assert wrapper._savepoint_allowed() is False def test_is_usable_always_true(self): - """Test is_usable always returns True.""" from django_cf.db.base_engine import CFDatabaseWrapper - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper.is_usable() - - assert result is True + assert wrapper.is_usable() is True def test_run_query_not_implemented(self): - """Test run_query raises NotImplementedError.""" from django_cf.db.base_engine import CFDatabaseWrapper - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - with pytest.raises(NotImplementedError): wrapper.run_query("SELECT 1") class TestCFDatabaseOperations: - """Tests for the CFDatabaseOperations class.""" - def test_bulk_insert_sql(self): - """Test bulk_insert_sql generates correct SQL.""" from django_cf.db.base_engine import CFDatabaseOperations - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) + ops = CFDatabaseOperations(MagicMock()) fields = ["id", "name"] placeholder_rows = [("%s", "%s"), ("%s", "%s")] - - result = ops.bulk_insert_sql(fields, placeholder_rows) - - assert result == "VALUES (%s, %s), (%s, %s)" + assert ( + ops.bulk_insert_sql(fields, placeholder_rows) == "VALUES (%s, %s), (%s, %s)" + ) def test_last_executed_query_no_params(self): - """Test last_executed_query with no parameters.""" from django_cf.db.base_engine import CFDatabaseOperations - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - + ops = CFDatabaseOperations(MagicMock()) sql = "SELECT * FROM test" - result = ops.last_executed_query(None, sql, None) - assert result == sql + assert ops.last_executed_query(None, sql, None) == sql def test_last_executed_query_with_params(self): - """Test last_executed_query with parameters.""" from django_cf.db.base_engine import CFDatabaseOperations mock_wrapper = MagicMock() mock_wrapper.connection = MagicMock() - mock_cursor = MagicMock() - mock_wrapper.connection.cursor.return_value = mock_cursor + mock_wrapper.connection.cursor.return_value = MagicMock() ops = CFDatabaseOperations(mock_wrapper) - sql = "SELECT * FROM test WHERE id = %s" - # The actual result depends on _quote_params_for_last_executed_query - # which may return None, causing substitution to either work or fall back - result = ops.last_executed_query(None, sql, [1]) + result = ops.last_executed_query(None, "SELECT * FROM test WHERE id = %s", [1]) - # Result should be a string (either substituted or original) assert isinstance(result, str) assert "SELECT * FROM test WHERE id" in result def test_last_executed_query_catches_formatting_errors(self): - """Test last_executed_query catches Exception on format failure.""" from django_cf.db.base_engine import CFDatabaseOperations - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - - # Mismatched format string and params to trigger formatting error + ops = CFDatabaseOperations(MagicMock()) sql = "SELECT * FROM test WHERE id = %s AND name = %s" - params = (1,) # Too few params for the format string - - result = ops.last_executed_query(None, sql, params) - # Should fall back to returning original sql on formatting error - assert result == sql + assert ops.last_executed_query(None, sql, (1,)) == sql def test_last_executed_query_does_not_catch_keyboard_interrupt(self): - """Test last_executed_query does not suppress KeyboardInterrupt.""" from django_cf.db.base_engine import CFDatabaseOperations - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) + ops = CFDatabaseOperations(MagicMock()) - # Object whose __str__ raises KeyboardInterrupt during sql % params class BadStr: def __str__(self): raise KeyboardInterrupt() @@ -700,71 +546,60 @@ def __str__(self): def __format__(self, spec): raise KeyboardInterrupt() - # Patch _quote_params to return a tuple with our bad object ops._quote_params_for_last_executed_query = lambda params: (BadStr(),) - sql = "SELECT * FROM test WHERE id = %s" - with pytest.raises(KeyboardInterrupt): - ops.last_executed_query(None, sql, [1]) + ops.last_executed_query(None, "SELECT * FROM test WHERE id = %s", [1]) class TestCFSQLCompiler: - """Tests for the CFSQLCompiler class.""" - def test_replace_date_trunc_functions_year(self): - """Test _replace_date_trunc_functions for year truncation.""" from django_cf.db.base_engine import CFSQLCompiler compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('year', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('year', created_at) FROM orders" + ) assert 'STRFTIME("%Y-01-01", created_at)' in result assert "django_date_trunc" not in result def test_replace_date_trunc_functions_month(self): - """Test _replace_date_trunc_functions for month truncation.""" from django_cf.db.base_engine import CFSQLCompiler compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('month', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('month', created_at) FROM orders" + ) assert 'STRFTIME("%Y-%m-01", created_at)' in result def test_replace_date_trunc_functions_day(self): - """Test _replace_date_trunc_functions for day truncation.""" from django_cf.db.base_engine import CFSQLCompiler compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('day', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('day', created_at) FROM orders" + ) assert "DATE(created_at)" in result def test_replace_date_trunc_functions_hour(self): - """Test _replace_date_trunc_functions for hour truncation.""" from django_cf.db.base_engine import CFSQLCompiler compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('hour', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('hour', created_at) FROM orders" + ) assert 'STRFTIME("%Y-%m-%d %H:00:00", created_at)' in result def test_replace_date_trunc_functions_unknown_kind(self): - """Test _replace_date_trunc_functions with unknown kind returns original.""" from django_cf.db.base_engine import CFSQLCompiler compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('unknown', created_at) FROM orders" + ) - sql = "SELECT django_date_trunc('unknown', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - # Unknown kind should leave the original match assert "django_date_trunc('unknown', created_at)" in result diff --git a/packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py b/packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py new file mode 100644 index 00000000..b697f128 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py @@ -0,0 +1,379 @@ +"""Cloudflare Access tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +import base64 +import importlib +import json +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +def base64url_encode(data): + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + +def create_jwt_parts(header, payload): + return base64url_encode(json.dumps(header)), base64url_encode(json.dumps(payload)) + + +def create_test_jwt(payload, kid="test-key-1"): + header = {"alg": "RS256", "typ": "JWT", "kid": kid} + header_b64, payload_b64 = create_jwt_parts(header, payload) + signature_b64 = base64url_encode(b"fake-signature") + return f"{header_b64}.{payload_b64}.{signature_b64}" + + +def make_request(path="/api/resource/"): + return SimpleNamespace(META={}, COOKIES={}, path=path, session=MagicMock()) + + +def middleware_module(): + return importlib.import_module("django_cf.middleware.CloudflareAccessMiddleware") + + +def make_middleware( + monkeypatch, + *, + aud: str | None = "test-aud-12345", + team_name: str | None = "testteam", + exempt_paths=None, + cache_timeout=3600, +): + from django.conf import settings + + exempt_paths = [] if exempt_paths is None else exempt_paths + monkeypatch.setattr(settings, "CLOUDFLARE_ACCESS_AUD", aud, raising=False) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_TEAM_NAME", team_name, raising=False + ) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_EXEMPT_PATHS", exempt_paths, raising=False + ) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_CACHE_TIMEOUT", cache_timeout, raising=False + ) + return middleware_module().CloudflareAccessMiddleware(lambda request: request) + + +class TestJWTExtraction: + def test_extract_jwt_from_header(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-jwt-token" + + assert middleware._extract_jwt_token(request) == "header-jwt-token" + + def test_extract_jwt_from_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.COOKIES["CF_Authorization"] = "cookie-jwt-token" + + assert middleware._extract_jwt_token(request) == "cookie-jwt-token" + + def test_extract_jwt_from_lowercase_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.COOKIES["cf_authorization"] = "lowercase-cookie-jwt-token" + + assert middleware._extract_jwt_token(request) == "lowercase-cookie-jwt-token" + + def test_extract_jwt_no_token(self, monkeypatch): + assert make_middleware(monkeypatch)._extract_jwt_token(make_request()) is None + + def test_no_broken_dash_header_lookup(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP-CF-ACCESS-JWT-ASSERTION"] = "broken-token" + + assert middleware._extract_jwt_token(request) is None + + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "good-token" + assert middleware._extract_jwt_token(request) == "good-token" + + def test_header_takes_precedence_over_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-token" + request.COOKIES["CF_Authorization"] = "cookie-token" + + assert middleware._extract_jwt_token(request) == "header-token" + + +class TestTeamNameExtraction: + def test_extract_team_name_from_valid_jwt(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + jwt_token = create_test_jwt( + { + "iss": "https://myteam.cloudflareaccess.com", + "email": "user@example.com", + } + ) + + assert middleware._extract_team_name_from_jwt(jwt_token) == "myteam" + + def test_extract_team_name_invalid_issuer_format(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"iss": "https://other-issuer.com", "email": "user@example.com"} + ) + + assert middleware._extract_team_name_from_jwt(jwt_token) is None + + def test_extract_team_name_missing_issuer(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._extract_team_name_from_jwt( + create_test_jwt({"email": "user@example.com"}) + ) + is None + ) + + def test_extract_team_name_malformed_jwt(self, monkeypatch): + assert ( + make_middleware(monkeypatch)._extract_team_name_from_jwt("invalid.jwt") + is None + ) + + +class TestExemptPaths: + def test_exempt_path_matches(self, monkeypatch): + middleware = make_middleware(monkeypatch, exempt_paths=["/health/", "/public/"]) + + assert middleware._is_exempt_path("/health/") is True + assert middleware._is_exempt_path("/health/check") is True + assert middleware._is_exempt_path("/public/") is True + assert middleware._is_exempt_path("/public/resource") is True + + def test_non_exempt_path(self, monkeypatch): + middleware = make_middleware(monkeypatch, exempt_paths=["/health/", "/public/"]) + + assert middleware._is_exempt_path("/api/") is False + assert middleware._is_exempt_path("/admin/") is False + assert middleware._is_exempt_path("/") is False + + def test_empty_exempt_paths(self, monkeypatch): + assert make_middleware(monkeypatch)._is_exempt_path("/any/path/") is False + + +class TestMiddlewareInitialization: + def test_init_with_aud_only(self, monkeypatch): + middleware = make_middleware(monkeypatch, team_name=None) + + assert middleware.aud == "test-aud-12345" + assert middleware.team_name is None + assert middleware.team_domain is None + assert middleware.certs_url is None + + def test_init_with_team_name_only(self, monkeypatch): + middleware = make_middleware(monkeypatch, aud=None) + + assert middleware.aud is None + assert middleware.team_name == "testteam" + assert middleware.team_domain == "testteam.cloudflareaccess.com" + assert ( + middleware.certs_url + == "https://testteam.cloudflareaccess.com/cdn-cgi/access/certs" + ) + + def test_init_with_both_settings(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert middleware.aud == "test-aud-12345" + assert middleware.team_name == "testteam" + + def test_init_without_required_settings(self, monkeypatch): + with pytest.raises(ValueError) as exc_info: + make_middleware(monkeypatch, aud=None, team_name=None) + + assert "Either CLOUDFLARE_ACCESS_AUD or CLOUDFLARE_ACCESS_TEAM_NAME" in str( + exc_info.value + ) + + +class TestBase64UrlDecode: + def test_base64url_decode_standard(self, monkeypatch): + middleware = make_middleware(monkeypatch) + encoded = base64url_encode(b"hello world") + + assert middleware._base64url_decode(encoded) == b"hello world" + + def test_base64url_decode_with_padding_needed(self, monkeypatch): + assert make_middleware(monkeypatch)._base64url_decode("YWI") == b"ab" + + +class TestJWTDecodeAndVerify: + def test_decode_jwt_invalid_format(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._decode_and_verify_jwt( + "only.two", {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_key_id_mismatch(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt({"email": "test@example.com"}, kid="key-1") + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "key-2", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_unsupported_algorithm(self, monkeypatch): + middleware = make_middleware(monkeypatch) + header = {"alg": "HS256", "typ": "JWT", "kid": "test-key"} + payload = {"email": "test@example.com"} + header_b64, payload_b64 = create_jwt_parts(header, payload) + jwt_token = f"{header_b64}.{payload_b64}.{base64url_encode(b'fake-signature')}" + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_expired_token(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"email": "test@example.com", "exp": int(time.time()) - 3600}, + kid="test-key", + ) + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_not_yet_valid(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"email": "test@example.com", "nbf": int(time.time()) + 3600}, + kid="test-key", + ) + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + +class TestRSAKeyProcessing: + def test_process_rsa_key_valid(self, monkeypatch): + middleware = make_middleware(monkeypatch) + result = middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "n": base64url_encode(b"\x00\x01\x00\x01"), + "e": base64url_encode(b"\x01\x00\x01"), + } + ) + + assert result is not None + assert result["kid"] == "test-key-1" + assert "n" in result + assert "e" in result + + def test_process_rsa_key_missing_n(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "e": base64url_encode(b"\x01\x00\x01"), + } + ) + is None + ) + + def test_process_rsa_key_missing_e(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "n": base64url_encode(b"\x00\x01\x00\x01"), + } + ) + is None + ) + + +class TestUserProvisioning: + def test_get_or_create_user_creates_user(self, monkeypatch): + module = middleware_module() + middleware = make_middleware(monkeypatch) + + created = {} + + class FakeDoesNotExist(Exception): + pass + + class FakeManager: + def get(self, email): + raise FakeDoesNotExist() + + def create_user(self, **kwargs): + created.update(kwargs) + return SimpleNamespace(**kwargs) + + fake_user_model = SimpleNamespace( + objects=FakeManager(), DoesNotExist=FakeDoesNotExist + ) + monkeypatch.setattr(module, "User", fake_user_model) + + user = middleware._get_or_create_user("user@example.com", "Test User") + + assert user.email == "user@example.com" + assert created["username"] == "user@example.com" + assert created["first_name"] == "Test" + assert created["last_name"] == "User" + assert created["is_active"] is True + + def test_get_or_create_user_updates_existing_name(self, monkeypatch): + module = middleware_module() + middleware = make_middleware(monkeypatch) + existing = SimpleNamespace( + email="user@example.com", + first_name="Old", + last_name="Name", + save=MagicMock(), + ) + existing.get_full_name = ( + lambda: f"{existing.first_name} {existing.last_name}".strip() + ) + + class FakeManager: + def get(self, email): + return existing + + fake_user_model = SimpleNamespace(objects=FakeManager(), DoesNotExist=Exception) + monkeypatch.setattr(module, "User", fake_user_model) + + user = middleware._get_or_create_user("user@example.com", "New Name") + + assert user is existing + assert existing.first_name == "New" + assert existing.last_name == "Name" + existing.save.assert_called_once_with() diff --git a/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py b/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py new file mode 100644 index 00000000..d4e6ed5f --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py @@ -0,0 +1,250 @@ +"""D1 backend tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from types import SimpleNamespace + +import pytest +import workers + + +def make_d1_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.d1.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + wrapper.binding = "DB" + wrapper.run_sync = lambda value: value + return wrapper + + +class FakeD1Statement: + def __init__( + self, *, raw_result=None, all_result=None, raw_error=None, all_error=None + ): + self.raw_result = raw_result or [] + self.all_result = all_result or {"results": [], "meta": {}} + self.raw_error = raw_error + self.all_error = all_error + self.bound_params = None + + def bind(self, *params): + self.bound_params = params + return self + + def raw(self): + if self.raw_error is not None: + raise self.raw_error + return self.raw_result + + def all(self): + if self.all_error is not None: + raise self.all_error + return self.all_result + + +class FakeD1Binding: + def __init__(self, statement): + self.statement = statement + self.prepared_queries = [] + + def prepare(self, query): + self.prepared_queries.append(query) + return self.statement + + +class TestD1DatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_all_null_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", [None, None] + ) + + assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" + assert result_params == [] + + def test_process_query_mixed_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s", + ["test", None, 25, None], + ) + + assert result_params == ["test", 25] + assert result_query.count("?") == 2 + assert result_query.count("null") == 2 + + def test_process_query_with_defer_foreign_keys(self): + result = make_d1_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + def test_process_query_date_trunc_replacement(self): + wrapper = make_d1_wrapper() + + result_query, _ = wrapper.process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in result_query + assert "CASE" in result_query or "STRFTIME" in result_query + + +class TestD1DatabaseWrapperConfiguration: + def test_vendor_name(self): + from django_cf.db.backends.d1.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_d1" + + def test_display_name(self): + from django_cf.db.backends.d1.base import DatabaseWrapper + + assert DatabaseWrapper.display_name == "D1" + + +class TestD1GetConnectionParams: + def test_missing_binding_raises_error(self): + from django.core.exceptions import ImproperlyConfigured + + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": None} + + with pytest.raises(ImproperlyConfigured) as exc_info: + wrapper.get_connection_params() + + assert "CLOUDFLARE_BINDING" in str(exc_info.value) + + def test_valid_binding_returns_params(self): + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} + + assert wrapper.get_connection_params() == {"binding": "MY_DB"} + + +class TestD1ExceptionHandling: + @pytest.mark.xfail( + reason=( + "The `except Exception: raise Error(Error.new().stack)` handler in `run_query` swallows this and re-raises a JS Error. Removing that handler is a separate change; these pass once it lands." + ), + strict=True, + ) + def test_run_query_lets_binding_errors_propagate(self, monkeypatch): + wrapper = make_d1_wrapper() + error = RuntimeError("binding boom") + monkeypatch.setattr( + workers, + "env", + SimpleNamespace(DB=FakeD1Binding(FakeD1Statement(raw_error=error))), + ) + + with pytest.raises(RuntimeError, match="binding boom"): + wrapper.run_query("SELECT * FROM test") + + +class TestD1RunQuery: + def test_read_query_returns_rows(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement(raw_result=[[1, "hello"], [2, "world"]]) + binding = FakeD1Binding(statement) + monkeypatch.setattr(workers, "env", SimpleNamespace(DB=binding)) + + result = wrapper.run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "hello"), (2, "world")] + assert statement.bound_params == (1,) + assert binding.prepared_queries == ["SELECT * FROM test WHERE id = ?"] + + def test_write_query_returns_meta(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement( + all_result={ + "results": [["ok"]], + "meta": {"rows_read": 1, "rows_written": 2, "last_row_id": 9}, + } + ) + monkeypatch.setattr( + workers, "env", SimpleNamespace(DB=FakeD1Binding(statement)) + ) + + result = wrapper.run_query("INSERT INTO test VALUES (%s)", ["x"]) + + assert list(result) == [("ok",)] + assert result.rowcount == 2 + assert result.lastrowid == 9 + + +class TestD1ParameterHandling: + def test_empty_params_list(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query("SELECT * FROM users", []) + + assert result_query == "SELECT * FROM users" + assert result_params == [] + + def test_special_characters_in_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", ["test'; DROP TABLE users; --"] + ) + + assert result_params == ["test'; DROP TABLE users; --"] + assert "?" in result_query + + def test_unicode_params(self): + wrapper = make_d1_wrapper() + + _, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", [""] + ) + + assert result_params == [""] + + def test_large_number_of_params(self): + wrapper = make_d1_wrapper() + + placeholders = ", ".join(["%s"] * 50) + result_query, result_params = wrapper.process_query( + f"INSERT INTO test VALUES ({placeholders})", list(range(50)) + ) + + assert result_query.count("?") == 50 + assert len(result_params) == 50 diff --git a/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py b/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py new file mode 100644 index 00000000..9505928b --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py @@ -0,0 +1,200 @@ +"""Durable Objects backend tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from types import SimpleNamespace + +import pytest + + +def make_d1_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.d1.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + wrapper.binding = "DB" + wrapper.run_sync = lambda value: value + return wrapper + + +def make_do_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.do.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + return wrapper + + +class FakeDOArray: + def __init__(self, data): + self.data = data + + def toArray(self): + return self + + def to_py(self): + return self.data + + +class FakeDOStatement: + def __init__(self, data, *, rows_read=0, rows_written=0): + self.data = data + self.rowsRead = rows_read + self.rowsWritten = rows_written + + def raw(self): + return FakeDOArray(self.data) + + +class FakeDOStorage: + def __init__(self, statement=None, *, error=None): + self.statement = statement + self.error = error + self.calls = [] + + def exec(self, query, *params): + if self.error is not None: + raise self.error + self.calls.append((query, params)) + return self.statement + + +class TestDODatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_with_defer_foreign_keys(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + +class TestDODatabaseWrapperConfiguration: + def test_vendor_name(self): + from django_cf.db.backends.do.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_durable_objects" + assert DatabaseWrapper.display_name == "DO" + + def test_get_connection_params_returns_empty(self): + assert make_do_wrapper().get_connection_params() == {} + + +class TestDOMissingDateTruncBug: + def test_do_process_query_missing_date_trunc(self): + d1_result, _ = make_d1_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + do_result, _ = make_do_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in d1_result + assert "django_date_trunc" in do_result + + +class TestDOStorageInitialization: + def test_storage_module_exists(self): + from django_cf.db.backends.do import storage + + storage.set_storage(None) + assert storage.get_storage() is None + + def test_run_query_uses_configured_storage(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([[1, "ok"]], rows_read=1)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "ok")] + assert fake_storage.calls == [("SELECT * FROM test WHERE id = ?", (1,))] + + +class TestDOQueryExecution: + def test_read_query_uses_raw(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([], rows_read=0, rows_written=0)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM users") + + assert result.fetchall() == [] + + +class TestDOExceptionHandling: + def test_run_query_lets_binding_errors_propagate(self): + from django_cf.db.backends.do import storage + + storage.set_storage(FakeDOStorage(error=RuntimeError("storage boom"))) + + with pytest.raises(RuntimeError, match="storage boom"): + make_do_wrapper().run_query("SELECT * FROM test") + + +class TestDOParamConversion: + def test_boolean_true_not_converted_in_process_query(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (active) VALUES (%s)", [True] + ) + + assert result_query == "INSERT INTO test (active) VALUES (?)" + assert result_params == [True] + + def test_multiple_none_params_order_preserved(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)", + [1, None, 2, None], + ) + + assert result_params == [1, 2] + assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" + + +class TestDOPragmaReturnTypeBug: + def test_pragma_returns_string_not_tuple(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert isinstance(result, str) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py b/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py new file mode 100644 index 00000000..c6d0808f --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py @@ -0,0 +1,247 @@ +"""R2 storage tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from uuid import uuid4 + +import django.conf +import pytest +import workers + + +def unique_name(prefix: str) -> str: + return f"{prefix}-{uuid4().hex}" + + +def make_r2_storage(**kwargs): + from django_cf.storage.r2 import R2Storage + + return R2Storage(**kwargs) + + +def make_live_r2_storage(**kwargs): + pyodide_run_sync = None + try: + from pyodide.ffi import run_sync as pyodide_run_sync + except ImportError: + pytest.skip("R2Storage requires pyodide.ffi.run_sync (JSPI)") + assert pyodide_run_sync is not None + + storage = make_r2_storage(**kwargs) + storage._bucket = workers.env.BUCKET + storage._run_sync = pyodide_run_sync + return storage + + +def save_bytes(storage, name: str, data: bytes) -> str: + return storage._save(name, BytesIO(data)) + + +class TestR2StorageInitialization: + def test_init_default_values(self): + storage = make_r2_storage() + + assert storage.binding == "BUCKET" + assert storage.location == "" + assert storage.allow_overwrite is False + assert storage._bucket is None + + def test_init_custom_values(self): + storage = make_r2_storage( + binding="MY_BUCKET", location="uploads/", allow_overwrite=True + ) + + assert storage.binding == "MY_BUCKET" + assert storage.location == "uploads" + assert storage.allow_overwrite is True + + def test_init_strips_slashes_from_location(self): + assert make_r2_storage(location="/uploads/files/").location == "uploads/files" + + +class TestR2StorageFullPath: + def test_full_path_no_location(self): + assert ( + make_r2_storage(location="")._full_path("test/file.txt") == "test/file.txt" + ) + + def test_full_path_with_location(self): + assert ( + make_r2_storage(location="uploads")._full_path("test/file.txt") + == "uploads/test/file.txt" + ) + + +class TestR2StorageUrlErrors: + def test_url_raises_without_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace()) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_raises_with_empty_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace(MEDIA_URL="")) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_constructs_correct_path(self, monkeypatch): + storage = make_r2_storage(location="uploads") + monkeypatch.setattr( + django.conf, "settings", SimpleNamespace(MEDIA_URL="/media/") + ) + + assert storage.url("file.txt") == "/media/uploads/file.txt" + + +class TestR2StorageReadErrors: + def test_read_returns_none_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-read")) + + assert storage._read("nonexistent.txt") is None + + +class TestR2StorageExistsErrors: + def test_exists_returns_false_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-exists")) + + assert storage.exists("nonexistent.txt") is False + + +class TestR2StorageSizeErrors: + def test_size_returns_zero_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-size")) + + assert storage.size("nonexistent.txt") == 0 + + +class TestR2StorageGetModifiedTimeErrors: + def test_get_modified_time_returns_now_for_missing_file(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-modified")) + + result = storage.get_modified_time("nonexistent.txt") + + assert abs((datetime.now() - result).total_seconds()) < 5 + + +class TestR2StorageGetAvailableName: + def test_get_available_name_raises_on_too_long(self): + storage = make_r2_storage() + + with pytest.raises(Exception, match="too long"): + storage.get_available_name("a" * 300, max_length=100) + + def test_get_available_name_returns_original_when_allow_overwrite(self): + storage = make_r2_storage(allow_overwrite=True) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_returns_original_when_not_exists(self): + storage = make_live_r2_storage(location=unique_name("r2-available-original")) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_increments_counter(self): + storage = make_live_r2_storage(location=unique_name("r2-available-counter")) + + save_bytes(storage, "file.txt", b"first") + save_bytes(storage, "file_1.txt", b"second") + + assert storage.get_available_name("file.txt") == "file_2.txt" + + +class TestR2FileClass: + def test_r2file_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-read")) + name = unique_name("read") + ".txt" + save_bytes(storage, name, b"test content") + + assert R2File(name, storage, mode="rb").read() == b"test content" + + def test_r2file_write_raises_in_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-readonly")) + name = unique_name("readonly") + ".txt" + save_bytes(storage, name, b"existing") + r2file = R2File(name, storage, mode="rb") + + with pytest.raises(AttributeError, match="not opened for writing"): + r2file.write(b"new content") + + def test_r2file_write_allowed_in_write_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-write")) + r2file = R2File(unique_name("write") + ".txt", storage, mode="wb") + + assert r2file.write(b"new content") == 11 + + def test_r2file_close(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-close")) + name = unique_name("close") + ".txt" + save_bytes(storage, name, b"content") + r2file = R2File(name, storage) + _ = r2file.file + + assert r2file._file is not None + + r2file.close() + assert r2file._file.closed is True + + +class TestR2StorageSaveEdgeCases: + def test_save_with_file_like_object(self): + storage = make_live_r2_storage(location=unique_name("r2-save-filelike")) + name = unique_name("test") + ".txt" + + assert save_bytes(storage, name, b"test content") == name + assert storage._read(name) == b"test content" + + +class TestR2StorageListdirEdgeCases: + def test_listdir_with_empty_path(self): + storage = make_live_r2_storage(location=unique_name("r2-empty-listdir")) + + directories, files = storage.listdir("") + + assert directories == [] + assert files == [] + + def test_listdir_scopes_to_directory_prefix(self): + storage = make_live_r2_storage(location=unique_name("r2-listdir-scope")) + + save_bytes(storage, "uploads/file.txt", b"file") + save_bytes(storage, "uploads2/other.txt", b"other") + + directories, files = storage.listdir("uploads") + + assert directories == [] + assert files == ["file.txt"] + + +class TestR2StorageTimeMethodsFallback: + def test_get_accessed_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-accessed-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_accessed_time("file.txt") == expected + + def test_get_created_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-created-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_created_time("file.txt") == expected diff --git a/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py b/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py new file mode 100644 index 00000000..99d6fecb --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py @@ -0,0 +1,30 @@ +"""WSGI handler tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +import pytest + + +class TestDjangoCFErrorMessages: + def test_djangocf_get_app_error_message(self): + from django_cf import DjangoCF + + cf = DjangoCF() + with pytest.raises(NotImplementedError) as exc_info: + cf.get_app() + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) + + def test_djangocf_durable_object_get_app_error_message(self): + from django_cf import DjangoCFDurableObject + + with pytest.raises(NotImplementedError) as exc_info: + DjangoCFDurableObject.get_app(None) + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) diff --git a/packages/django-cf/tests/in_worker/worker/src/worker.py b/packages/django-cf/tests/in_worker/worker/src/worker.py new file mode 100644 index 00000000..962bc9cc --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/worker.py @@ -0,0 +1,182 @@ +# pyright: reportMissingImports=false + +import asyncio +import contextlib +import importlib.util +import io +from pathlib import Path +from urllib.parse import urlparse + +import django +import django.conf +import pytest +from django.http import HttpResponse, JsonResponse +from workers import Response, WorkerEntrypoint + +BASE_DIR = Path(__file__).parent + +if not django.conf.settings.configured: + django.conf.settings.configure( + DEBUG=False, + SECRET_KEY="django-cf-host-only-tests", + ROOT_URLCONF=__name__, + ALLOWED_HOSTS=["*"], + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + ], + MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + }, + SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies", + USE_TZ=True, + TIME_ZONE="UTC", + DEFAULT_AUTO_FIELD="django.db.models.AutoField", + ) + +django.setup() + +from django_cf import handle_wsgi # noqa: E402 + +urlpatterns = [] + + +def _wsgi_header_echo_app(environ, start_response): + payload = { + "cf_access": environ.get("HTTP_CF_ACCESS_JWT_ASSERTION"), + "custom": environ.get("HTTP_X_CUSTOM_HEADER"), + "content_type": environ.get("CONTENT_TYPE"), + "content_length": environ.get("CONTENT_LENGTH"), + } + return JsonResponse(payload) + + +def _wsgi_body_echo_app(environ, start_response): + length = int(environ.get("CONTENT_LENGTH") or 0) + body = environ["wsgi.input"].read(length) + return HttpResponse(body, content_type="application/octet-stream") + + +class ResultCollector: + def __init__(self): + self.results = {} + + @staticmethod + def _key(item): + normalized = [] + if item.cls is not None: + normalized.append(item.cls.__name__) + name = getattr(item, "originalname", None) or item.name + normalized.append(name[len("test_") :] if name.startswith("test_") else name) + return "__".join(normalized) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_makereport(self, item, call): + outcome = yield + report = outcome.get_result() + key = self._key(item) + + if report.when == "call": + if report.passed: + self.results[key] = {"status": "passed"} + elif report.skipped: + self.results[key] = { + "status": "skipped", + "reason": str(report.longrepr), + } + elif report.failed: + excinfo = call.excinfo + if excinfo is not None and excinfo.errisinstance(AssertionError): + self.results[key] = { + "status": "failed", + "error": str(excinfo.value), + } + else: + self.results[key] = { + "status": "error", + "error": f"{excinfo.typename}: {excinfo.value}" + if excinfo is not None + else "unknown error", + "traceback": report.longreprtext, + } + elif report.when in ("setup", "teardown") and report.skipped: + self.results[key] = { + "status": "skipped", + "reason": str(report.longrepr), + } + elif report.when in ("setup", "teardown") and report.failed: + self.results[key] = { + "status": "error", + "error": report.longreprtext, + "traceback": report.longreprtext, + } + + +class EnvPlugin: + def __init__(self, env): + self._env = env + + @pytest.fixture + def env(self): + return self._env + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + path = urlparse(request.url).path + + if path.startswith("/run-tests/"): + suite_name = path[len("/run-tests/") :] + return self._run_suite(suite_name) + if path == "/health": + return Response.json({"ok": True}) + if path == "/wsgi/headers": + return await handle_wsgi(request, _wsgi_header_echo_app) + if path == "/wsgi/body": + return await handle_wsgi(request, _wsgi_body_echo_app) + return Response.json({"error": "not found"}, status=404) + + def _run_suite(self, suite_name): + module = f"test_{suite_name}" + if importlib.util.find_spec(module) is None: + return Response.json( + {"error": f"Unknown suite '{suite_name}' (no module '{module}')"}, + status=404, + ) + + collector = ResultCollector() + saved_loop = asyncio.events._get_running_loop() + output = io.StringIO() + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + exit_code = pytest.main( + ["--pyargs", module, "-p", "no:cacheprovider"], + plugins=[collector, EnvPlugin(self.env)], + ) + finally: + asyncio.events._set_running_loop(saved_loop) + if exit_code != 0 and not collector.results: + return Response.json( + { + "__session__": { + "status": "error", + "error": f"pytest exit code {exit_code}", + "traceback": output.getvalue(), + } + }, + status=500, + ) + return Response.json(collector.results) diff --git a/packages/django-cf/tests/in_worker/worker/wrangler.jsonc b/packages/django-cf/tests/in_worker/worker/wrangler.jsonc new file mode 100644 index 00000000..a26997b3 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "django-cf-host-only-tests", + "main": "src/worker.py", + "compatibility_date": "%COMPAT_DATE", + "compatibility_flags": ["python_workers"], + "r2_buckets": [ + { + "binding": "BUCKET", + "bucket_name": "django-test-bucket", + "preview_bucket_name": "django-test-bucket-preview" + } + ] +} diff --git a/packages/django-cf/tests/middleware/__init__.py b/packages/django-cf/tests/middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/django-cf/tests/middleware/test_cloudflare_access.py b/packages/django-cf/tests/middleware/test_cloudflare_access.py deleted file mode 100644 index 3952fe96..00000000 --- a/packages/django-cf/tests/middleware/test_cloudflare_access.py +++ /dev/null @@ -1,951 +0,0 @@ -"""Tests for CloudflareAccessMiddleware.""" - -import base64 -import json -import time -from unittest.mock import MagicMock, patch - -import pytest - - -# Helper functions to create test JWTs -def base64url_encode(data): - """Encode bytes to base64url string without padding.""" - if isinstance(data, str): - data = data.encode("utf-8") - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") - - -def create_jwt_parts(header, payload): - """Create the header and payload parts of a JWT.""" - header_b64 = base64url_encode(json.dumps(header)) - payload_b64 = base64url_encode(json.dumps(payload)) - return header_b64, payload_b64 - - -def create_test_jwt(payload, kid="test-key-1"): - """Create a test JWT (unsigned, for testing extraction functions).""" - header = {"alg": "RS256", "typ": "JWT", "kid": kid} - header_b64, payload_b64 = create_jwt_parts(header, payload) - # Fake signature for structure testing - signature_b64 = base64url_encode(b"fake-signature") - return f"{header_b64}.{payload_b64}.{signature_b64}" - - -@pytest.fixture -def mock_django_settings(): - """Mock Django settings for middleware.""" - settings = MagicMock() - settings.CLOUDFLARE_ACCESS_AUD = "test-aud-12345" - settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - return settings - - -@pytest.fixture -def mock_user_model(): - """Mock Django User model.""" - user = MagicMock() - user.email = "test@example.com" - user.first_name = "Test" - user.last_name = "User" - user.get_full_name.return_value = "Test User" - return user - - -@pytest.fixture -def mock_request(): - """Create a mock Django request.""" - request = MagicMock() - request.META = {} - request.COOKIES = {} - request.path = "/api/resource/" - request.session = MagicMock() - return request - - -class TestJWTExtraction: - """Tests for JWT token extraction from request.""" - - def test_extract_jwt_from_header(self, mock_request): - """Test extracting JWT from CF-Access-Jwt-Assertion header.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "header-jwt-token" - mock_request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_from_cookie(self, mock_request): - """Test extracting JWT from CF_Authorization cookie.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "cookie-jwt-token" - mock_request.COOKIES["CF_Authorization"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_from_lowercase_cookie(self, mock_request): - """Test extracting JWT from cf_authorization cookie (lowercase).""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "lowercase-cookie-jwt-token" - mock_request.COOKIES["cf_authorization"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_no_token(self, mock_request): - """Test that None is returned when no JWT is present.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - result = middleware._extract_jwt_token(mock_request) - assert result is None - - def test_no_broken_dash_header_lookup(self, mock_request): - """Test that _extract_jwt_token does not try to look up headers with dashes. - - Previously the code had a fallback that looked up - 'HTTP_CF_ACCESS_JWT_ASSERTION'.replace('_', '-') which evaluated to - 'HTTP-CF-ACCESS-JWT-ASSERTION' - a key that could never exist in - Django's request.META (Django always uses underscores for HTTP headers). - """ - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Verify the source code does NOT contain the broken dash-based lookup - import inspect - - source = inspect.getsource(middleware._extract_jwt_token) - assert ".replace('_', '-')" not in source - assert '.replace("_", "-")' not in source - - # Without header or cookie, should return None (no broken fallback) - mock_request.META = {} - mock_request.COOKIES = {} - result = middleware._extract_jwt_token(mock_request) - assert result is None - - # With correct underscore-based header key, should find it - mock_request.META = {"HTTP_CF_ACCESS_JWT_ASSERTION": "test-token"} - result = middleware._extract_jwt_token(mock_request) - assert result == "test-token" - - def test_header_takes_precedence_over_cookie(self, mock_request): - """Test that header JWT takes precedence over cookie.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - mock_request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-token" - mock_request.COOKIES["CF_Authorization"] = "cookie-token" - - result = middleware._extract_jwt_token(mock_request) - assert result == "header-token" - - -class TestTeamNameExtraction: - """Tests for team name extraction from JWT.""" - - def test_extract_team_name_from_valid_jwt(self): - """Test extracting team name from JWT issuer claim.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = { - "iss": "https://myteam.cloudflareaccess.com", - "email": "user@example.com", - } - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result == "myteam" - - def test_extract_team_name_invalid_issuer_format(self): - """Test that None is returned for non-Cloudflare issuer.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = { - "iss": "https://other-issuer.com", - "email": "user@example.com", - } - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result is None - - def test_extract_team_name_missing_issuer(self): - """Test that None is returned when issuer is missing.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = {"email": "user@example.com"} - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result is None - - def test_extract_team_name_malformed_jwt(self): - """Test that None is returned for malformed JWT.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # JWT with only 2 parts instead of 3 - result = middleware._extract_team_name_from_jwt("invalid.jwt") - assert result is None - - -class TestExemptPaths: - """Tests for exempt path functionality.""" - - def test_exempt_path_matches(self): - """Test that exempt paths are correctly identified.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/health/") is True - assert middleware._is_exempt_path("/health/check") is True - assert middleware._is_exempt_path("/public/") is True - assert middleware._is_exempt_path("/public/resource") is True - - def test_non_exempt_path(self): - """Test that non-exempt paths are correctly identified.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/api/") is False - assert middleware._is_exempt_path("/admin/") is False - assert middleware._is_exempt_path("/") is False - - def test_empty_exempt_paths(self): - """Test behavior with no exempt paths configured.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/any/path/") is False - - -class TestMiddlewareInitialization: - """Tests for middleware initialization.""" - - def test_init_with_aud_only(self): - """Test initialization with only AUD setting.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = None - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud == "test-aud" - assert middleware.team_name is None - assert middleware.team_domain is None - assert middleware.certs_url is None - - def test_init_with_team_name_only(self): - """Test initialization with only team name setting.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = None - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud is None - assert middleware.team_name == "testteam" - assert middleware.team_domain == "testteam.cloudflareaccess.com" - assert ( - middleware.certs_url - == "https://testteam.cloudflareaccess.com/cdn-cgi/access/certs" - ) - - def test_init_with_both_settings(self): - """Test initialization with both AUD and team name.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud == "test-aud" - assert middleware.team_name == "testteam" - - def test_init_without_required_settings(self): - """Test that ValueError is raised without required settings.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = None - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = None - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - with pytest.raises(ValueError) as exc_info: - CloudflareAccessMiddleware(lambda r: r) - - assert ( - "Either CLOUDFLARE_ACCESS_AUD or CLOUDFLARE_ACCESS_TEAM_NAME" - in str(exc_info.value) - ) - - -class TestBase64UrlDecode: - """Tests for base64url decoding.""" - - def test_base64url_decode_standard(self): - """Test standard base64url decoding.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Test with known value - encoded = base64url_encode(b"hello world") - result = middleware._base64url_decode(encoded) - assert result == b"hello world" - - def test_base64url_decode_with_padding_needed(self): - """Test base64url decoding with padding that needs to be added.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # 'ab' encodes to 'YWI' which needs 1 padding char - result = middleware._base64url_decode("YWI") - assert result == b"ab" - - -class TestJWTDecodeAndVerify: - """Tests for JWT decoding and verification.""" - - def test_decode_jwt_invalid_format(self): - """Test that invalid JWT format returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - # JWT with wrong number of parts - result = middleware._decode_and_verify_jwt("only.two", key_data) - assert result is None - - def test_decode_jwt_key_id_mismatch(self): - """Test that key ID mismatch returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with one key ID - payload = {"email": "test@example.com"} - jwt_token = create_test_jwt(payload, kid="key-1") - - # Try to verify with different key ID - key_data = {"kid": "key-2", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_unsupported_algorithm(self): - """Test that unsupported algorithm returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with HS256 algorithm - header = {"alg": "HS256", "typ": "JWT", "kid": "test-key"} - payload = {"email": "test@example.com"} - header_b64, payload_b64 = create_jwt_parts(header, payload) - signature_b64 = base64url_encode(b"fake-signature") - jwt_token = f"{header_b64}.{payload_b64}.{signature_b64}" - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_expired_token(self): - """Test that expired token returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create expired JWT - payload = { - "email": "test@example.com", - "exp": int(time.time()) - 3600, # Expired 1 hour ago - } - jwt_token = create_test_jwt(payload, kid="test-key") - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_not_yet_valid(self): - """Test that not-yet-valid token returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with nbf in the future - payload = { - "email": "test@example.com", - "nbf": int(time.time()) + 3600, # Valid in 1 hour - } - jwt_token = create_test_jwt(payload, kid="test-key") - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - -class TestRSAKeyProcessing: - """Tests for RSA key processing from JWK format.""" - - def test_process_rsa_key_valid(self): - """Test processing valid RSA key from JWK.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Sample JWK with small values for testing - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "n": base64url_encode( - b"\x00\x01\x00\x01" - ), # Small modulus for testing - "e": base64url_encode(b"\x01\x00\x01"), # Exponent (65537) - } - - result = middleware._process_rsa_key(key_info) - - assert result is not None - assert result["kid"] == "test-key-1" - assert "n" in result - assert "e" in result - - def test_process_rsa_key_missing_n(self): - """Test that missing modulus returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "e": base64url_encode(b"\x01\x00\x01"), - # 'n' is missing - } - - result = middleware._process_rsa_key(key_info) - assert result is None - - def test_process_rsa_key_missing_e(self): - """Test that missing exponent returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "n": base64url_encode(b"\x00\x01\x00\x01"), - # 'e' is missing - } - - result = middleware._process_rsa_key(key_info) - assert result is None diff --git a/packages/django-cf/tests/r2/test_storage.py b/packages/django-cf/tests/r2/test_storage.py index ee3d1894..0903d8e9 100644 --- a/packages/django-cf/tests/r2/test_storage.py +++ b/packages/django-cf/tests/r2/test_storage.py @@ -1,7 +1,5 @@ import requests -from ..utils import r2_web_server # noqa: F401 - def test_r2_upload_file(r2_web_server): """Test uploading a file to R2 storage.""" diff --git a/packages/django-cf/tests/r2/test_storage_errors.py b/packages/django-cf/tests/r2/test_storage_errors.py deleted file mode 100644 index 84b9e837..00000000 --- a/packages/django-cf/tests/r2/test_storage_errors.py +++ /dev/null @@ -1,668 +0,0 @@ -"""Tests for R2 storage error handling and edge cases.""" - -from io import BytesIO -from unittest.mock import MagicMock, patch - -import pytest - - -class TestR2StorageInitialization: - """Tests for R2Storage initialization.""" - - def test_init_default_values(self): - """Test R2Storage initializes with default values.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - assert storage.binding == "BUCKET" - assert storage.location == "" - assert storage.allow_overwrite is False - assert storage._bucket is None - - def test_init_custom_values(self): - """Test R2Storage initializes with custom values.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage( - binding="MY_BUCKET", location="uploads/", allow_overwrite=True - ) - - assert storage.binding == "MY_BUCKET" - assert storage.location == "uploads" # Trailing slash stripped - assert storage.allow_overwrite is True - - def test_init_strips_slashes_from_location(self): - """Test that leading/trailing slashes are stripped from location.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="/uploads/files/") - - assert storage.location == "uploads/files" - - -class TestR2StorageFullPath: - """Tests for R2Storage._full_path method.""" - - def test_full_path_no_location(self): - """Test _full_path without location prefix.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="") - result = storage._full_path("test/file.txt") - - assert result == "test/file.txt" - - def test_full_path_with_location(self): - """Test _full_path with location prefix.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="uploads") - result = storage._full_path("test/file.txt") - - assert result == "uploads/test/file.txt" - - -class TestR2StorageGetBucketErrors: - """Tests for R2Storage._get_bucket error handling.""" - - def test_get_bucket_raises_on_non_worker(self): - """Test _get_bucket raises exception when not in worker.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - with patch("django_cf.storage.r2.R2Storage._get_bucket") as mock_get: - mock_get.side_effect = Exception("Code not running inside a worker!") - - with pytest.raises(Exception) as exc_info: - storage._get_bucket() - - assert "not running inside a worker" in str(exc_info.value) - - -class TestR2StorageReadErrors: - """Tests for R2Storage._read error handling.""" - - def test_read_returns_none_on_not_found(self): - """Test _read returns None when file not found.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(return_value=None) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - result = storage._read("nonexistent.txt") - - assert result is None - - def test_read_returns_none_on_exception(self): - """Test _read returns None on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Network error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - # _get_bucket will try to use the mock - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage._read("file.txt") - - assert result is None - - -class TestR2StorageExistsErrors: - """Tests for R2Storage.exists error handling.""" - - def test_exists_returns_false_on_exception(self): - """Test exists returns False on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Permission denied")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.exists("file.txt") - - assert result is False - - -class TestR2StorageSizeErrors: - """Tests for R2Storage.size error handling.""" - - def test_size_returns_zero_on_exception(self): - """Test size returns 0 on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.size("file.txt") - - assert result == 0 - - def test_size_returns_zero_when_no_size_attribute(self): - """Test size returns 0 when metadata has no size attribute.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_metadata = MagicMock(spec=[]) # No 'size' attribute - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = mock_metadata - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.size("file.txt") - - assert result == 0 - - -class TestR2StorageUrlErrors: - """Tests for R2Storage.url error handling.""" - - def test_url_raises_without_media_url(self): - """Test url raises ValueError when MEDIA_URL not configured.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - # Create a mock settings object - class MockSettings: - MEDIA_URL = None - - # Patch the import inside the url() method - with patch.dict( - "sys.modules", {"django.conf": MagicMock(settings=MockSettings())} - ): - # Need to reload to pick up the mock, but since the import is local, - # we just need to make sure hasattr works correctly - pass - - # Alternative approach: mock hasattr and settings directly - mock_settings = MagicMock(spec=[]) # Empty spec = no MEDIA_URL attribute - with patch("django.conf.settings", mock_settings): - with pytest.raises(ValueError) as exc_info: - storage.url("file.txt") - - assert "MEDIA_URL must be configured" in str(exc_info.value) - - def test_url_raises_with_empty_media_url(self): - """Test url raises ValueError when MEDIA_URL is empty.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - mock_settings = MagicMock() - mock_settings.MEDIA_URL = "" - - with patch("django.conf.settings", mock_settings): - with pytest.raises(ValueError) as exc_info: - storage.url("file.txt") - - assert "MEDIA_URL must be configured" in str(exc_info.value) - - def test_url_constructs_correct_path(self): - """Test url constructs correct URL with MEDIA_URL.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="uploads") - - mock_settings = MagicMock() - mock_settings.MEDIA_URL = "/media/" - - with patch("django.conf.settings", mock_settings): - result = storage.url("file.txt") - - assert result == "/media/uploads/file.txt" - - -class TestR2StorageGetModifiedTimeErrors: - """Tests for R2Storage.get_modified_time error handling.""" - - def test_get_modified_time_returns_now_on_exception(self): - """Test get_modified_time returns current time on exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - before = datetime.now() - result = storage.get_modified_time("file.txt") - after = datetime.now() - - assert before <= result <= after - - -class TestR2StorageGetAvailableName: - """Tests for R2Storage.get_available_name method.""" - - def test_get_available_name_raises_on_too_long(self): - """Test get_available_name raises exception when name too long.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - long_name = "a" * 300 - - with pytest.raises(Exception) as exc_info: - storage.get_available_name(long_name, max_length=100) - - assert "too long" in str(exc_info.value) - - def test_get_available_name_returns_original_when_allow_overwrite(self): - """Test get_available_name returns original name when allow_overwrite.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(allow_overwrite=True) - - result = storage.get_available_name("file.txt") - - assert result == "file.txt" - - def test_get_available_name_returns_original_when_not_exists(self): - """Test get_available_name returns original when file doesn't exist.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - with patch.object(storage, "exists", return_value=False): - result = storage.get_available_name("file.txt") - - assert result == "file.txt" - - def test_get_available_name_increments_counter(self): - """Test get_available_name increments counter for existing files.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - # Call sequence: - # 1. exists('file.txt') -> True (line 268, don't return early) - # 2. exists('file.txt') -> True (line 275 while check, enter loop, name becomes file_1.txt) - # 3. exists('file_1.txt') -> False (line 275 while check, exit loop) - with patch.object(storage, "exists", side_effect=[True, True, False]): - result = storage.get_available_name("file.txt") - - assert result == "file_1.txt" - - -class TestR2FileClass: - """Tests for R2File class.""" - - def test_r2file_read_mode(self): - """Test R2File in read mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"test content" - - r2file = R2File("test.txt", mock_storage, mode="rb") - - content = r2file.read() - assert content == b"test content" - - def test_r2file_write_raises_in_read_mode(self): - """Test R2File.write raises when in read mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"" - - r2file = R2File("test.txt", mock_storage, mode="rb") - - with pytest.raises(AttributeError) as exc_info: - r2file.write(b"new content") - - assert "not opened for writing" in str(exc_info.value) - - def test_r2file_write_allowed_in_write_mode(self): - """Test R2File.write works in write mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"" - - r2file = R2File("test.txt", mock_storage, mode="wb") - - # Access file property to initialize BytesIO - _ = r2file.file - - result = r2file.write(b"new content") - assert result == 11 # Length of 'new content' - - def test_r2file_close(self): - """Test R2File.close properly closes internal file.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"content" - - r2file = R2File("test.txt", mock_storage) - - # Access file to initialize it - _ = r2file.file - assert r2file._file is not None - - r2file.close() - # After close, the internal BytesIO should be closed - - -class TestR2StorageSaveEdgeCases: - """Tests for R2Storage._save edge cases.""" - - def test_save_with_file_like_object(self): - """Test _save with file-like object that has read() method.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - # Create a file-like object - content = BytesIO(b"test content") - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - with patch("django_cf.storage.r2.Uint8Array"): - result = storage._save("test.txt", content) - - assert result == "test.txt" - - def test_save_with_content_type(self): - """Test _save preserves content_type when available.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - # Create a file-like object with content_type - content = BytesIO(b'{"key": "value"}') - content.content_type = "application/json" - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - with patch("django_cf.storage.r2.Uint8Array"): - storage._save("data.json", content) - - # TODO: assert put was called with the expected httpMetadata options. - - -class TestR2StorageListdirEdgeCases: - """Tests for R2Storage.listdir edge cases.""" - - def test_listdir_with_empty_path(self): - """Test listdir with empty path.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = { - "objects": [], - "delimitedPrefixes": [], - } - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - dirs, files = storage.listdir("") - - assert dirs == [] - assert files == [] - - def test_listdir_adds_trailing_slash(self): - """Test listdir adds trailing slash to path.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = { - "objects": [], - "delimitedPrefixes": [], - } - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - storage.listdir("uploads") - - # Verify list was called with prefix ending in / - list_call = mock_bucket.list.call_args - assert list_call[0][0]["prefix"].endswith("/") - - -class TestR2StorageTimeMethodsFallback: - """Tests for R2Storage time methods fallback behavior.""" - - def test_get_accessed_time_returns_modified_time(self): - """Test get_accessed_time falls back to get_modified_time.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_time = datetime(2023, 1, 15, 12, 0, 0) - - with patch.object(storage, "get_modified_time", return_value=mock_time): - result = storage.get_accessed_time("file.txt") - - assert result == mock_time - - def test_get_created_time_returns_modified_time(self): - """Test get_created_time falls back to get_modified_time.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_time = datetime(2023, 1, 15, 12, 0, 0) - - with patch.object(storage, "get_modified_time", return_value=mock_time): - result = storage.get_created_time("file.txt") - - assert result == mock_time diff --git a/packages/django-cf/tests/test_date_trunc.py b/packages/django-cf/tests/test_date_trunc.py index 600edd15..4f52f226 100644 --- a/packages/django-cf/tests/test_date_trunc.py +++ b/packages/django-cf/tests/test_date_trunc.py @@ -1,7 +1,5 @@ import requests -from .utils import r2_web_server # noqa: F401 - def test_date_trunc_month(r2_web_server): """Test truncating dates to month.""" diff --git a/packages/django-cf/tests/test_wsgi_handler.py b/packages/django-cf/tests/test_wsgi_handler.py deleted file mode 100644 index ceba3ec1..00000000 --- a/packages/django-cf/tests/test_wsgi_handler.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for WSGI handler and DjangoCF classes.""" - -import pytest - -from django_cf import DjangoCF, DjangoCFDurableObject - - -class TestDjangoCFErrorMessages: - """Tests for DjangoCF class error messages.""" - - def test_djangocf_get_app_error_message(self): - """Test that DjangoCF.get_app() raises NotImplementedError with correct message.""" - cf = DjangoCF() - with pytest.raises(NotImplementedError) as exc_info: - cf.get_app() - # Verify no duplicate "implement" word - assert ( - str(exc_info.value) == "Please implement get_app in your django_cf worker" - ) - assert "implement implement" not in str(exc_info.value) - - def test_djangocf_durable_object_get_app_error_message(self): - """Test that DjangoCFDurableObject.get_app() raises NotImplementedError with correct message.""" - # DjangoCFDurableObject.__init__ requires ctx and env, so test get_app directly - assert ( - "implement get_app" in DjangoCFDurableObject.get_app.__code__.co_consts[1] - ) - # Verify no duplicate word in the source constant - for const in DjangoCFDurableObject.get_app.__code__.co_consts: - if isinstance(const, str) and "implement" in const: - assert "implement implement" not in const - - -class TestWSGIHeaderTransformation: - """Tests for WSGI header name transformation. - - Per PEP 3333 (WSGI spec), HTTP headers should be stored in the environ dict as - HTTP_HEADER_NAME where dashes are replaced with underscores and everything is uppercased. - - The handle_wsgi function can't be tested directly (requires Pyodide/js imports), - so we verify the transformation logic via source code inspection. - """ - - def test_header_transformation_replaces_dashes(self): - """Verify the header transformation code replaces dashes with underscores.""" - import inspect - - from django_cf import handle_wsgi - - source = inspect.getsource(handle_wsgi) - # The header loop should include .replace("-", "_") for WSGI compliance - assert '.replace("-", "_")' in source or ".replace('-', '_')" in source - - def test_header_transformation_logic_directly(self): - """Test the header transformation expression produces correct WSGI keys.""" - # This tests the exact expression used in handle_wsgi line 42: - # f'HTTP_{header[0].upper().replace("-", "_")}' - test_cases = [ - ("content-type", "HTTP_CONTENT_TYPE"), - ("x-forwarded-for", "HTTP_X_FORWARDED_FOR"), - ("cf-access-jwt-assertion", "HTTP_CF_ACCESS_JWT_ASSERTION"), - ("accept", "HTTP_ACCEPT"), - ("x-custom-header", "HTTP_X_CUSTOM_HEADER"), - ("cf-connecting-ip", "HTTP_CF_CONNECTING_IP"), - ] - for header_name, expected_key in test_cases: - result = f"HTTP_{header_name.upper().replace('-', '_')}" - assert result == expected_key, ( - f"Header '{header_name}' should map to '{expected_key}', got '{result}'" - ) diff --git a/packages/django-cf/tests/utils.py b/packages/django-cf/tests/utils.py deleted file mode 100644 index d53dae17..00000000 --- a/packages/django-cf/tests/utils.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import signal -import socket -import subprocess -import time - -import pytest -import requests - - -def get_free_port(): - """Find an available port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("localhost", 0)) - return s.getsockname()[1] - - -class WorkerFixture: - def __init__(self, migrate=True): - self.process = None - self.port = None - self.base_url = None - self.migrate = migrate - - def start(self, worker_dir): - """Start the worker in a subprocess.""" - self.port = get_free_port() - self.base_url = f"http://localhost:{self.port}" - - # Clean up previous wrangler state - wrangler_state_dir = os.path.join(worker_dir, ".wrangler") - if os.path.exists(wrangler_state_dir): - subprocess.run( - ["rm", "-rf", wrangler_state_dir], cwd=worker_dir, check=True - ) - - cmd_parts = ["npx", "wrangler", "dev", "--port", str(self.port)] - self.process = subprocess.Popen( - cmd_parts, - cwd=worker_dir, # Run npx from the worker directory - preexec_fn=os.setsid, # So we can kill the process group later - ) - - # Wait for server to start - self._wait_for_server() - - if self.migrate: - requests.get(f"{self.base_url}/__run_migrations__/") - requests.get(f"{self.base_url}/__create_admin__/") - - return self - - def _wait_for_server(self, max_retries=10, retry_interval=1): - """Wait until the server is responding to requests.""" - for _ in range(max_retries): - try: - response = requests.get(self.base_url, timeout=20) - if response.status_code < 500: # Accept any non-server error response - return - except requests.exceptions.RequestException: - pass - - time.sleep(retry_interval) - - # If we got here, the server didn't start properly - self.stop() - raise Exception(f"worker failed to start on port {self.port}") - - def stop(self): - """Stop the worker.""" - if self.process: - # Kill the process group (including any child processes) - os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) - self.process = None - - -@pytest.fixture(scope="session") -def durable_objects_web_server(): - """Pytest fixture that starts the worker for the entire test session.""" - worker_dir = os.path.join( - os.path.dirname(__file__), "..", "templates", "durable-objects" - ) - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() - - -@pytest.fixture(scope="session") -def d1_web_server(): - """Pytest fixture that starts the worker for the entire test session.""" - worker_dir = os.path.join(os.path.dirname(__file__), "..", "templates", "d1") - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() - - -@pytest.fixture(scope="session") -def r2_web_server(): - """Pytest fixture that starts the R2 test server for the entire test session.""" - worker_dir = os.path.join(os.path.dirname(__file__), "servers", "r2") - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() diff --git a/packages/django-cf/uv.lock b/packages/django-cf/uv.lock index e8198f8f..190ad2dd 100644 --- a/packages/django-cf/uv.lock +++ b/packages/django-cf/uv.lock @@ -86,6 +86,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +] + [[package]] name = "django" version = "6.0" @@ -102,29 +171,34 @@ wheels = [ [[package]] name = "django-cf" -version = "0.2.3" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "sqlparse" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "django" }, { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "requests" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [ - { name = "django", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "requests", marker = "extra == 'dev'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "sqlparse" }, +requires-dist = [{ name = "sqlparse" }] + +[package.metadata.requires-dev] +dev = [ + { name = "django" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "requests" }, + { name = "ruff" }, ] -provides-extras = ["dev"] [[package]] name = "idna" @@ -187,6 +261,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -237,6 +338,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "tzdata" version = "2025.2"