Template Tests for Django TemplateView Requiring Staff Permissions

A mixin to be used with testing Django TemplateView class-based views that require staff privileges.

Introduction

In the article How to Test a Django TemplateView Requiring Staff Permissions we explored how to test a Django class-based TemplateView that requires staff privileges to gain access. In this article, we'll abstract these tests as a mixin so they can be reused across staff-only views.

Set Up

In the previous article, we tested a simple TemplateView with a UserPassesTestMixin that enforces is_staff=True.

# demo.views.py

from django.views.generic import TemplateView
from django.contrib.auth.mixins import UserPassesTestMixin


class DashboardTemplateView(UserPassesTestMixin, TemplateView):
    template_name = 'demo/dashboard.html'
    raise_exception = True

    def test_func(self):
        return self.request.user.is_staff

Then, we implemented a test that checks a user without staff privileges is denied access.

from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model

from utils.test.view_test_mixins import TemplateViewTestMixin
from ..views import DashboardTemplateView


User = get_user_model()


class TestDashboardTemplateView(TemplateViewTestMixin, TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.view_class = DashboardTemplateView
        cls.template_name = 'demo/dashboard.html'
        cls.url = reverse('dashboard')
        cls.static_url = '/'

        # we need a user that has staff permissions for the client get
        #   request to succeed
        cls.user = User.objects.create(username='example', is_staff=True)

    def test_permission_denied(self):
        # make sure usernames don't collide
        user = User.objects.create(username="different-user")
        self.client.force_login(user)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 403)

We only have a single test method test_permission_denied that needs to be included in the mixin and there's not much in the way of data needing to be lifted up to the setUpTestData class method. That said, there're still changes we could implement to make this more robust. The username value of "different-user" probably isn't unique enough to guarantee non-collision, etc.

The Mixin

To ensure this test works properly and our username doesn't collide with user objects created elsewhere, I'll use the uuid python library to create a unique username.

import uuid

from django.contrib.auth import get_user_model

User = get_user_model()

user = User.objects.create(username=f'user-{str(uuid.uuid4())}')

Note, I'm not worried about the length of the username field here even with using the full UUID. As a reminder, a version 4 UUID is 32 hexadecimal characters split into five groups by four dashes making a total length of 36. Our prefix ("user-") has a total length of 5 making our username just 41 characters.

>>> import uuid
>>> print(len(f"user-{str(uuid.uuid4())}"))
41

That's a pretty long username, no doubt. But at least we know it's almost certainly unique. You could slice this down if you wanted to. However, the default Django username field has a max length of 150 characters so we're well within the margin.

Implemented, this looks like:

# demo.tests.test_dashboard_template_view.py

    ...

    def test_staff_only(self):
        # make sure usernames don't collide
        user = User.objects.create(username=f'user-{str(uuid.uuid4())}')
        self.client.force_login(user)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 403)

That will work more times than not. What will cause this to fail, however, is if the user model used in the app isn't the Django default user model and the "username" field has been changed to something else. Note, I changed the test name from the prior article to test_staff_only to be more specific to this particular implementation.

Custom User Model

If the project where this test is being used has a custom user model implemented, this test will not work properly if a field other than "username" is set as USERNAME_FIELD. I wouldn't want to cover every possible case exhaustively in a template test, but there's at least one alternative that's quite common and can be covered without much effort: the "email" field set as username.

from django.contrib.auth import get_user_model
from django.contrib.auth.models import User as DjangoUser
from django.core.exceptions import ValidationError, ImproperlyConfigured

User = get_user_model()

...

    def test_staff_only(self):
        username = f'user-{str(uuid.uuid4())}'
        user = User()

        if isinstance(User, DjangoUser):
            # default user model, set username and move on
            user.username = username
            user.save()
        else:
            # not the default user model
            if User.USERNAME_FIELD == User.EMAIL_FIELD:
                setattr(user, User.EMAIL_FIELD, f'{username}@example.com')
            else:
                # try to insert username anyways and catch any problems
                setattr(user, User.USERNAME_FIELD, username)
            try:
                user.save()
            except ValidationError:
                raise ImproperlyConfigured('Unable to create user for test.')

        # if we're this far, we have a user
        self.client.force_login(user)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 403)

This solution is by no means perfect. If there are other required fields, for example, this fails. But in many cases, this will handle the common situation of the "email" field being substituted for the "username" field.

Other Considerations

This approach works for the Django mixin UserPassesTest. If the decorator user_passes_test is used instead of the mixin, this test won't succeed because our status code won't be a 403, but instead a 302. This is because the user_passes_test decorator redirects to the "login" page rather than raising a 403 "permission denied" exception.

# demo.views.py

from django.views.generic import TemplateView
# from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.decorators import user_passes_test
from django.utils.decorators import method_decorator


def is_staff_user(user):
    return user.is_staff


@method_decorator(user_passes_test(is_staff_user), name='dispatch')
class DashboardTemplateView(TemplateView):
    template_name = 'demo/dashboard.html'
    # raise_exception = True
    #
    # def test_func(self):
    #     return self.request.user.is_staff

# run the test

python manage.py test --verbosity 2

# output


test_staff_only (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_staff_only) ... FAIL

======================================================================
FAIL: test_staff_only (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_staff_only)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "django-userpassestest-test-utility/demo/tests/test_dashboard_template_view.py", line 52, in test_staff_only
    self.assertEqual(response.status_code, 403)
AssertionError: 302 != 403

Alternatively, even if you are using the mixin but the method handle_no_permission has been overridden to cause a redirect, you'll still run into issues.

Keep in mind how is_staff is being enforced on the view when using this test approach.

Complete Mixin

Let's take a look at what our complete mixin looks like with all the considerations and implementations above included.

# utils.test.auth_test_mixins.py

import uuid

from django.contrib.auth import get_user_model
from django.contrib.auth.models import User as DjangoUser
from django.core.exceptions import ValidationError, ImproperlyConfigured


User = get_user_model()


# noinspection PyUnresolvedReferences
class StaffUserTestMixin:

    def test_staff_only(self):
        username = f'user-{str(uuid.uuid4())}'
        user = User()
    
        if isinstance(User, DjangoUser):
            # default user model, set username and move on
            user.username = username
            user.save()
        else:
            # not the default user model
            if User.USERNAME_FIELD == User.EMAIL_FIELD:
                setattr(user, User.EMAIL_FIELD, f'{username}@example.com')
            else:
                # try to insert username anyways and catch any problems
                setattr(user, User.USERNAME_FIELD, username)
            try:
                user.save()
            except ValidationError:
                raise ImproperlyConfigured('Unable to create user for test.')
    
        # if we're this far, we have a user
        self.client.force_login(user)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 403)

Use

Finally, let's use our newly created mixin and run the tests.

# demo.tests.test_dashboard_template_view.py

from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model


from utils.test.view_test_mixins import TemplateViewTestMixin
from utils.test.auth_test_mixins import StaffUserTestMixin
from ..views import DashboardTemplateView


User = get_user_model()


class TestDashboardTemplateView(
    TemplateViewTestMixin,
    StaffUserTestMixin,
    TestCase
):

    @classmethod
    def setUpTestData(cls):
        cls.view_class = DashboardTemplateView
        cls.template_name = 'demo/dashboard.html'
        cls.url = reverse('dashboard')
        cls.static_url = '/'

        # we need a user that has staff permissions for the client get
        #   request to succeed
        cls.user = User.objects.create(username='example', is_staff=True)

Note, the ordering of the two mixins isn't important. You could switch the two mixins and the tests would run just fine and actually the ordering of when the tests run wouldn't be affected.

python manage.py test --verbosity 2

# output

test_correct_template (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_correct_template) ... ok
test_good_location (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_good_location) ... ok
test_good_status_code (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_good_status_code) ... ok
test_staff_only (demo.tests.test_dashboard_template_view.TestDashboardTemplateView.test_staff_only) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.044s

OK

All tests are passing. We've now fully tested our view without any unique code aside from establishing the appropriate set up data. If we're in a situation where we need to test that many views must only be accessible to staff users, this mixin will save considerable time.

Final Thoughts

Testing for good coverage can be tedious. You'll find that you often test similar components in similar ways. When it feels like you're beginning to repeat yourself, that's a sign you may want to abstract that code into a mixin.

Importantly, repetitive processes can lead to mistakes. Especially with tests like these where we're concerned with access and security, we don't want any errors where privileged resources can become exposed to the public. By making the testing process simpler, we're reducing the probability of these errors or omissions. By abstracting repetitive code, we're freeing up resources to be allocated to feature building.

Source code available on Github.

Details
Published
November 9, 2023
Next
September 25, 2023

Test Logging in Django

An easy way to test Django logging to ensure your logs are being created as expected.