FEAT: Modernized to support latest Pythons and DRF JSONAPI
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def pytest_configure():
|
||||
if not settings.configured:
|
||||
settings.configure(
|
||||
SECRET_KEY="test-only-key",
|
||||
INSTALLED_APPS=[
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"rest_framework",
|
||||
"rest_framework_json_api",
|
||||
],
|
||||
DATABASES={
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
}
|
||||
},
|
||||
REST_FRAMEWORK={
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework_json_api.renderers.JSONRenderer",
|
||||
],
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework_json_api.parsers.JSONParser",
|
||||
],
|
||||
},
|
||||
USE_TZ=True,
|
||||
)
|
||||
django.setup()
|
||||
@@ -0,0 +1,123 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
from enhanced_drf_jsonapi.api import (
|
||||
HardenedGenericAPIView,
|
||||
HardenedModelViewSet,
|
||||
PreloadIncludesMixin,
|
||||
ReasonableModelSerializer,
|
||||
ReasonableModelViewSet,
|
||||
)
|
||||
from enhanced_drf_jsonapi.exceptions import HardenedExceptionHandlingMixin
|
||||
|
||||
|
||||
class QuerySetSpy:
|
||||
def __init__(self):
|
||||
self.select_calls = []
|
||||
self.prefetch_calls = []
|
||||
|
||||
def select_related(self, *fields):
|
||||
self.select_calls.append(fields)
|
||||
return self
|
||||
|
||||
def prefetch_related(self, *fields):
|
||||
self.prefetch_calls.append(fields)
|
||||
return self
|
||||
|
||||
|
||||
class QuerySetProvider:
|
||||
queryset_spy = None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
return self.queryset_spy
|
||||
|
||||
|
||||
class IncludeView(PreloadIncludesMixin, QuerySetProvider):
|
||||
select_for_includes = {
|
||||
"__all__": ["owner"],
|
||||
"author": ["author", "author__profile"],
|
||||
}
|
||||
prefetch_for_includes = {
|
||||
"author": ["author__books"],
|
||||
"comments": lambda view: [f"comments_for_{view.request.marker}"],
|
||||
}
|
||||
|
||||
|
||||
def test_preload_includes_applies_requested_and_unconditional_rules(monkeypatch):
|
||||
queryset = QuerySetSpy()
|
||||
view = IncludeView()
|
||||
view.queryset_spy = queryset
|
||||
view.request = SimpleNamespace(marker="request")
|
||||
monkeypatch.setattr(
|
||||
"enhanced_drf_jsonapi.api.get_included_resources",
|
||||
lambda request: ["author", "comments"],
|
||||
)
|
||||
|
||||
assert view.get_queryset() is queryset
|
||||
assert queryset.select_calls == [("author", "author__profile"), ("owner",)]
|
||||
assert queryset.prefetch_calls == [
|
||||
("author__books",),
|
||||
("comments_for_request",),
|
||||
]
|
||||
|
||||
|
||||
def serializer_fields(monkeypatch, method, *, include="", data=None, configured=True):
|
||||
monkeypatch.setattr(
|
||||
serializers.ModelSerializer,
|
||||
"get_field_names",
|
||||
lambda self, declared_fields, info: ["id", "name"],
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
method=method,
|
||||
query_params={"include": include} if include else {},
|
||||
data=data or {},
|
||||
)
|
||||
serializer = ReasonableModelSerializer(context={"request": request})
|
||||
if configured:
|
||||
serializer.included_serializers = {"owner": object, "comments": object}
|
||||
return serializer.get_field_names({}, None)
|
||||
|
||||
|
||||
def test_serializer_adds_requested_get_relationships_without_duplicates(monkeypatch):
|
||||
fields = serializer_fields(monkeypatch, "GET", include="owner,comments,missing,owner")
|
||||
assert fields == ["id", "name", "owner", "comments"]
|
||||
|
||||
|
||||
def test_serializer_adds_relationships_from_post_and_patch_payloads(monkeypatch):
|
||||
assert serializer_fields(monkeypatch, "POST", data={"owner": {}, "other": 1}) == [
|
||||
"id",
|
||||
"name",
|
||||
"owner",
|
||||
]
|
||||
assert serializer_fields(monkeypatch, "PATCH", data={"comments": []}) == [
|
||||
"id",
|
||||
"name",
|
||||
"comments",
|
||||
]
|
||||
|
||||
|
||||
def test_serializer_is_safe_without_request_or_included_serializers(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
serializers.ModelSerializer,
|
||||
"get_field_names",
|
||||
lambda self, declared_fields, info: ["id"],
|
||||
)
|
||||
assert ReasonableModelSerializer().get_field_names({}, None) == ["id"]
|
||||
assert serializer_fields(monkeypatch, "GET", include="owner", configured=False) == [
|
||||
"id",
|
||||
"name",
|
||||
]
|
||||
|
||||
|
||||
def test_view_classes_preserve_methods_and_offer_opt_in_hardening():
|
||||
assert ReasonableModelViewSet.http_method_names == [
|
||||
"get",
|
||||
"post",
|
||||
"patch",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
]
|
||||
assert issubclass(HardenedModelViewSet, HardenedExceptionHandlingMixin)
|
||||
assert issubclass(HardenedGenericAPIView, HardenedExceptionHandlingMixin)
|
||||
@@ -0,0 +1,67 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from enhanced_drf_jsonapi.exceptions import (
|
||||
ERROR_ID_HEADER,
|
||||
APIConflictException,
|
||||
HardenedExceptionHandlingMixin,
|
||||
hardened_exception_handler,
|
||||
)
|
||||
|
||||
|
||||
class ExceptionView(HardenedExceptionHandlingMixin, APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
exception = None
|
||||
|
||||
def get(self, request):
|
||||
raise self.exception
|
||||
|
||||
|
||||
def get_response(exception):
|
||||
view = ExceptionView.as_view(exception=exception)
|
||||
request = APIRequestFactory().get(
|
||||
"/failure",
|
||||
HTTP_ACCEPT="application/vnd.api+json",
|
||||
)
|
||||
response = view(request)
|
||||
response.render()
|
||||
return response
|
||||
|
||||
|
||||
def test_expected_api_exception_keeps_its_status_without_error_id():
|
||||
response = get_response(NotFound("missing"))
|
||||
assert response.status_code == 404
|
||||
assert ERROR_ID_HEADER not in response
|
||||
assert b"missing" in response.rendered_content
|
||||
|
||||
|
||||
def test_deliberate_conflict_is_a_sanitized_409():
|
||||
response = get_response(APIConflictException())
|
||||
assert response.status_code == 409
|
||||
assert ERROR_ID_HEADER not in response
|
||||
assert b"current resource state" in response.rendered_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[RuntimeError("database-password=secret"), IntegrityError("private row value")],
|
||||
)
|
||||
def test_unexpected_exception_is_logged_and_sanitized(exception, caplog):
|
||||
with caplog.at_level("ERROR"):
|
||||
response = get_response(exception)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert re.fullmatch(r"[0-9a-f]{32}", response[ERROR_ID_HEADER])
|
||||
assert b"unexpected server error" in response.rendered_content
|
||||
assert str(exception).encode() not in response.rendered_content
|
||||
assert response[ERROR_ID_HEADER] in caplog.text
|
||||
|
||||
|
||||
def test_mixin_selects_the_hardened_handler():
|
||||
assert ExceptionView().get_exception_handler() is hardened_exception_handler
|
||||
@@ -0,0 +1,56 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from enhanced_drf_jsonapi.pagination import NgxJsonApiPageNumberPagination
|
||||
|
||||
|
||||
def make_page(*, number, pages, count, per_page, next_page=None, previous_page=None):
|
||||
return SimpleNamespace(
|
||||
number=number,
|
||||
paginator=SimpleNamespace(num_pages=pages, count=count, per_page=per_page),
|
||||
has_next=lambda: next_page is not None,
|
||||
has_previous=lambda: previous_page is not None,
|
||||
next_page_number=lambda: next_page,
|
||||
previous_page_number=lambda: previous_page,
|
||||
)
|
||||
|
||||
|
||||
def test_pagination_response_preserves_ngx_jsonapi_contract():
|
||||
pagination = NgxJsonApiPageNumberPagination()
|
||||
pagination.page = make_page(
|
||||
number=2,
|
||||
pages=4,
|
||||
count=37,
|
||||
per_page=10,
|
||||
next_page=3,
|
||||
previous_page=1,
|
||||
)
|
||||
pagination.build_link = lambda page: None if page is None else f"/items?page={page}"
|
||||
|
||||
response = pagination.get_paginated_response([{"id": "one"}])
|
||||
|
||||
assert response.data == {
|
||||
"results": [{"id": "one"}],
|
||||
"meta": {
|
||||
"page": 2,
|
||||
"pages": 4,
|
||||
"total_resources": 37,
|
||||
"resources_per_page": 10,
|
||||
},
|
||||
"links": {
|
||||
"first": "/items?page=1",
|
||||
"last": "/items?page=4",
|
||||
"next": "/items?page=3",
|
||||
"prev": "/items?page=1",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_pagination_uses_null_links_at_the_boundaries():
|
||||
pagination = NgxJsonApiPageNumberPagination()
|
||||
pagination.page = make_page(number=1, pages=1, count=0, per_page=25)
|
||||
pagination.build_link = lambda page: None if page is None else f"/items?page={page}"
|
||||
|
||||
response = pagination.get_paginated_response([])
|
||||
|
||||
assert response.data["links"]["next"] is None
|
||||
assert response.data["links"]["prev"] is None
|
||||
Reference in New Issue
Block a user