215 lines
5.4 KiB
Python
215 lines
5.4 KiB
Python
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}"
|
|
)
|