from __future__ import annotations from dataclasses import dataclass from typing import Iterable from uuid import UUID import pytest from django.contrib.auth import get_user_model from django.test import Client from django.urls import URLPattern, URLResolver, get_resolver, reverse SAFE_STATUS_CODES = { 200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404, 405, } SKIP_NAMES = { "admin:index", # admin app has its own dedicated tests and redirects heavily. } SKIP_NAMESPACES = { "admin", "tinymce", "cookies", } STANDARD_NAME_HINTS = { "index", "home", "help", "about", "privacy", "stats", "list", "detail", "view", "overview", "profile", "inbox", "messages", "people", "server", "logs", "trainees", "normals", "categories", "search_page", } NON_STANDARD_NAME_HINTS = { "create", "update", "delete", "remove", "add", "edit", "toggle", "submit", "save", "bulk", "json", "dicom", "viewer", "download", "upload", "anonymise", "migrate", "truncate", "image_size", "series", "mark", "review", "sync", "reset", "api", } @dataclass(frozen=True) class NamedRoute: name: str kwargs: dict[str, object] def _default_for_converter(converter_name: str) -> object: if converter_name == "int": return 1 if converter_name == "str": return "smoke" if converter_name == "slug": return "smoke" if converter_name == "uuid": return UUID("00000000-0000-0000-0000-000000000001") if converter_name == "path": return "smoke/path" return "smoke" def _iter_named_patterns( patterns: Iterable[URLPattern | URLResolver], namespace: str = "", ) -> Iterable[NamedRoute]: for pattern in patterns: if isinstance(pattern, URLResolver): nested_namespace = namespace if pattern.namespace: nested_namespace = f"{namespace}:{pattern.namespace}" if namespace else pattern.namespace yield from _iter_named_patterns(pattern.url_patterns, nested_namespace) continue if not pattern.name: continue full_name = f"{namespace}:{pattern.name}" if namespace else pattern.name converters = getattr(pattern.pattern, "converters", {}) kwargs = {key: _default_for_converter(converter.__class__.__name__.replace("Converter", "").lower()) for key, converter in converters.items()} yield NamedRoute(name=full_name, kwargs=kwargs) def _all_named_routes() -> list[NamedRoute]: resolver = get_resolver() routes = list(_iter_named_patterns(resolver.url_patterns)) unique_routes: dict[tuple[str, tuple[tuple[str, object], ...]], NamedRoute] = {} for route in routes: key = (route.name, tuple(sorted(route.kwargs.items()))) unique_routes[key] = route return sorted(unique_routes.values(), key=lambda route: (route.name, sorted(route.kwargs))) def _is_standard_route(route_name: str) -> bool: parts = route_name.split(":") namespace = parts[0] if len(parts) > 1 else "" leaf_name = parts[-1] if namespace in SKIP_NAMESPACES: return False if route_name in SKIP_NAMES: return False if any(hint in leaf_name for hint in NON_STANDARD_NAME_HINTS): return False return any(hint in leaf_name for hint in STANDARD_NAME_HINTS) ALL_NAMED_ROUTES = _all_named_routes() STANDARD_NAMED_ROUTES = [route for route in ALL_NAMED_ROUTES if _is_standard_route(route.name)] @pytest.fixture def smoke_client(db) -> Client: user_model = get_user_model() user = user_model.objects.create_user( username="view_smoke_user", password="testpassword", email="view_smoke_user@example.com", ) client = Client() client.raise_request_exception = False client.force_login(user) return client @pytest.mark.django_db @pytest.mark.parametrize("route", STANDARD_NAMED_ROUTES, ids=lambda route: route.name) def test_named_view_routes_do_not_return_server_errors(smoke_client: Client, route: NamedRoute): if route.name in SKIP_NAMES: pytest.skip("Route is intentionally excluded from this smoke suite.") try: url = reverse(route.name, kwargs=route.kwargs or None) except Exception as exc: # pragma: no cover pytest.skip(f"Unable to reverse route {route.name}: {exc}") response = smoke_client.get(url, follow=False) assert response.status_code in SAFE_STATUS_CODES, ( f"Unexpected status for {route.name} ({url}): {response.status_code}" ) @pytest.mark.django_db @pytest.mark.parametrize("route", STANDARD_NAMED_ROUTES, ids=lambda route: route.name) def test_named_view_routes_anonymous_do_not_return_server_errors(route: NamedRoute): if route.name in SKIP_NAMES: pytest.skip("Route is intentionally excluded from this smoke suite.") try: url = reverse(route.name, kwargs=route.kwargs or None) except Exception as exc: # pragma: no cover pytest.skip(f"Unable to reverse route {route.name}: {exc}") client = Client() client.raise_request_exception = False response = client.get(url, follow=False) assert response.status_code in SAFE_STATUS_CODES, ( f"Unexpected anonymous status for {route.name} ({url}): {response.status_code}" )