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.