Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: new permissions and fix test cases #61

Merged
merged 4 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .vscode/workspace.code-snippets
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"module.docstring": {
"prefix": [
"module.docstring",
"\"\"\"",
"'''"
],
"scope": "python",
"body": [
"\"\"\"",
"© Ocado Group",
"Created on $CURRENT_DATE/$CURRENT_MONTH/$CURRENT_YEAR at $CURRENT_HOUR:$CURRENT_MINUTE:$CURRENT_SECOND($CURRENT_TIMEZONE_OFFSET)."
"",
"${1:__description__}",
"\"\"\""
]
},
"module.doccomment": {
"prefix": [
"module.doccomment",
"/"
],
"scope": "javascript,typescript,javascriptreact,typescriptreact",
"body": [
"/**",
" * © Ocado Group",
" * Created on $CURRENT_DATE/$CURRENT_MONTH/$CURRENT_YEAR at $CURRENT_HOUR:$CURRENT_MINUTE:$CURRENT_SECOND($CURRENT_TIMEZONE_OFFSET)."
" *",
" * ${1:__description__}",
" */"
]
},
"pylint.disable-next": {
"prefix": [
"# pylint"
],
"scope": "python",
"body": [
"# pylint: disable-next=${1:__code_name__}"
]
},
"mypy.ignore": {
"prefix": [
"# type"
],
"scope": "python",
"body": [
"# type: ignore[${1:__code_name__}]"
]
}
}
8 changes: 8 additions & 0 deletions codeforlife/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
"""
© Ocado Group
Created on 23/01/2024 at 16:38:07(+00:00).

Reusable DRF permissions.
"""

from .allow_none import AllowNone
from .is_cron_request_from_google import IsCronRequestFromGoogle
18 changes: 18 additions & 0 deletions codeforlife/permissions/allow_none.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
© Ocado Group
Created on 23/01/2024 at 14:46:23(+00:00).
"""

from rest_framework.permissions import BasePermission


class AllowNone(BasePermission):
"""
Blocks all incoming requests.

This is the opposite of DRF's AllowAny permission:
https://www.django-rest-framework.org/api-guide/permissions/#allowany
"""

def has_permission(self, request, view):
return False
9 changes: 6 additions & 3 deletions codeforlife/permissions/is_cron_request_from_google.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""
© Ocado Group
Created on 23/01/2024 at 14:45:07(+00:00).
"""

from django.conf import settings
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import View


class IsCronRequestFromGoogle(BasePermission):
Expand All @@ -11,7 +14,7 @@ class IsCronRequestFromGoogle(BasePermission):
https://cloud.google.com/appengine/docs/flexible/scheduling-jobs-with-cron-yaml#securing_urls_for_cron
"""

def has_permission(self, request: Request, view: View):
def has_permission(self, request, view):
return (
settings.DEBUG
or request.META.get("HTTP_X_APPENGINE_CRON") == "true"
Expand Down
4 changes: 4 additions & 0 deletions codeforlife/serializers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ class ModelSerializer(_ModelSerializer[AnyModel], t.Generic[AnyModel]):
# pylint: disable-next=useless-parent-delegation
def update(self, instance, validated_data: t.Dict[str, t.Any]):
return super().update(instance, validated_data)

# pylint: disable-next=useless-parent-delegation
def create(self, validated_data: t.Dict[str, t.Any]):
return super().create(validated_data)
35 changes: 26 additions & 9 deletions codeforlife/tests/model_view_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
from django.utils import timezone
from django.utils.http import urlencode
from pyotp import TOTP
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.test import APIClient, APITestCase
from rest_framework.test import APIClient, APIRequestFactory, APITestCase
from rest_framework.viewsets import ModelViewSet

from ..user.models import AuthFactor, User
Expand All @@ -36,7 +37,12 @@ class ModelViewSetClient(
responses.
"""

Data = t.Dict[str, t.Any]
def __init__(self, enforce_csrf_checks: bool = False, **defaults):
super().__init__(enforce_csrf_checks, **defaults)
self.request_factory = APIRequestFactory(
enforce_csrf_checks,
**defaults,
)

_test_case: "ModelViewSetTestCase[AnyModelViewSet, AnyModelSerializer, AnyModel]"

Expand All @@ -61,6 +67,7 @@ def _model_view_set_class(self):
# pylint: disable-next=no-member
return self._test_case.get_model_view_set_class()

Data = t.Dict[str, t.Any]
StatusCodeAssertion = t.Optional[t.Union[int, t.Callable[[int], bool]]]
ListFilters = t.Optional[t.Dict[str, str]]

Expand Down Expand Up @@ -204,7 +211,7 @@ def generic(
def create(
self,
data: Data,
status_code_assertion: StatusCodeAssertion = None,
status_code_assertion: StatusCodeAssertion = status.HTTP_201_CREATED,
**kwargs,
):
"""Create a model.
Expand All @@ -219,6 +226,7 @@ def create(

response: Response = self.post(
self.reverse("list"),
data=data,
status_code_assertion=status_code_assertion,
**kwargs,
)
Expand All @@ -235,7 +243,7 @@ def create(
def retrieve(
self,
model: AnyModel,
status_code_assertion: StatusCodeAssertion = None,
status_code_assertion: StatusCodeAssertion = status.HTTP_200_OK,
**kwargs,
):
"""Retrieve a model.
Expand Down Expand Up @@ -265,7 +273,7 @@ def retrieve(
def list(
self,
models: t.Iterable[AnyModel],
status_code_assertion: StatusCodeAssertion = None,
status_code_assertion: StatusCodeAssertion = status.HTTP_200_OK,
filters: ListFilters = None,
**kwargs,
):
Expand Down Expand Up @@ -302,7 +310,7 @@ def partial_update(
self,
model: AnyModel,
data: Data,
status_code_assertion: StatusCodeAssertion = None,
status_code_assertion: StatusCodeAssertion = status.HTTP_200_OK,
**kwargs,
):
"""Partially update a model.
Expand Down Expand Up @@ -336,14 +344,16 @@ def partial_update(
def destroy(
self,
model: AnyModel,
status_code_assertion: StatusCodeAssertion = None,
status_code_assertion: StatusCodeAssertion = status.HTTP_204_NO_CONTENT,
anonymized: bool = False,
**kwargs,
):
"""Destroy a model.

Args:
model: The model to destroy.
status_code_assertion: The expected status code.
anonymized: Whether or not the data is anonymized.

Returns:
The HTTP response.
Expand All @@ -355,7 +365,10 @@ def destroy(
**kwargs,
)

# TODO: add standard post-destroy assertions.
if not anonymized and self.status_code_is_ok(response.status_code):
# pylint: disable-next=no-member
with self._test_case.assertRaises(model.DoesNotExist):
model.refresh_from_db()

return response

Expand All @@ -369,11 +382,15 @@ def login(self, **credentials):
if user.session.session_auth_factors.filter(
auth_factor__type=AuthFactor.Type.OTP
).exists():
request = self.request_factory.request()
request.user = user

now = timezone.now()
otp = TOTP(user.otp_secret).at(now)
with patch.object(timezone, "now", return_value=now):
assert super().login(
otp=otp
request=request,
otp=otp,
), f'Failed to login with OTP "{otp}" at {now}.'

assert user.is_authenticated, "Failed to authenticate user."
Expand Down