Recap
So far in this series we have built a layered test suite for a Django orders application.
Article 1 mapped the testing landscape: the test pyramid, the three types of tests, and the properties that make a test suite worth having. Article 2 built unit tests with Django's TestCase: model methods, validators, custom managers, service logic. Article 3 migrated that suite to pytest: plain functions, composable fixtures, parametrize, and the mocker fixture. Article 4 moved up the pyramid to integration tests: views, API endpoints, URL routing, middleware, serializer validation, filtering, pagination, and permission boundaries.
In this article we go deep on one of the most consequential areas of any web application: authentication and permissions. A bug here is not a broken feature. It is a security incident. Every auth boundary deserves an explicit test, and we are going to write them all.
Why auth tests matter
Authentication and permission bugs are special because they are often invisible in normal usage. A user who belongs to the right group never triggers the code path that incorrectly allows another group through. The application appears to work. The bug sits dormant until someone discovers it.
There are three categories of auth bug that automated tests catch:
- Missing protection: A view or endpoint that should require authentication does not. An anonymous user can access it.
- Overly broad permission: A user can access or modify a resource that belongs to another user or role.
- Broken rejection: A user is denied access they should legitimately have, producing a 403 or 404 where a 200 was expected.
All three bugs share a common cause: untested code paths. The fix is the same: write a test for every access boundary, every role, and every direction of failure.
If a permission boundary has no test, it has no guarantee. It works today because you checked manually. It will break the day someone refactors the view.
Testing login and logout
Django's built-in authentication views handle login and logout. Even if you use them without modification, you should test the flows that depend on them, because other parts of your code assume they work.
import pytest
from django.urls import reverse
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(
username="alice",
email="alice@example.com",
password="strongpassword123",
)
@pytest.mark.django_db
def test_login_with_valid_credentials_returns_302(client, user):
response = client.post(reverse("login"), {
"username": "alice",
"password": "strongpassword123",
})
assert response.status_code == 302
@pytest.mark.django_db
def test_login_redirects_to_next_url(client, user):
response = client.post(
reverse("login") + "?next=/dashboard/",
{"username": "alice", "password": "strongpassword123"},
)
assert response["Location"] == "/dashboard/"
@pytest.mark.django_db
def test_login_with_wrong_password_returns_200_with_errors(client, user):
response = client.post(reverse("login"), {
"username": "alice",
"password": "wrongpassword",
})
assert response.status_code == 200
assert response.context["form"].errors
@pytest.mark.django_db
def test_login_with_nonexistent_user_returns_200_with_errors(client):
response = client.post(reverse("login"), {
"username": "nobody",
"password": "anypassword",
})
assert response.status_code == 200
assert response.context["form"].errors
@pytest.mark.django_db
def test_login_with_empty_credentials_returns_200_with_errors(client):
response = client.post(reverse("login"), {
"username": "",
"password": "",
})
assert response.status_code == 200
assert response.context["form"].errors
@pytest.mark.django_db
def test_logout_ends_the_session(client, user):
client.force_login(user)
client.post(reverse("logout"))
response = client.get(reverse("orders:order-list"))
assert response.status_code == 302 # redirected to login: session is gone
@pytest.mark.django_db
def test_authenticated_user_visiting_login_page_is_redirected(client, user):
client.force_login(user)
response = client.get(reverse("login"))
assert response.status_code == 302The login tests cover four input scenarios: valid credentials, wrong password, nonexistent user, and empty submission. Each one is a distinct code path in the authentication backend. The wrong-password and nonexistent-user cases should produce identical responses so that attackers cannot enumerate valid usernames through error message differences.
✦ Tip
Test that wrong password and nonexistent user produce the same error
If your login view returns a different error message for 'user does not exist' versus 'wrong password', an attacker can enumerate valid usernames by submitting known usernames and watching for the different response. Assert that both cases produce the same form error text.
@pytest.mark.django_db
def test_login_errors_do_not_reveal_whether_user_exists(client, user):
wrong_password_response = client.post(reverse("login"), {
"username": "alice",
"password": "wrongpassword",
})
nonexistent_user_response = client.post(reverse("login"), {
"username": "nobody",
"password": "anypassword",
})
wrong_password_errors = wrong_password_response.context["form"].non_field_errors()
nonexistent_errors = nonexistent_user_response.context["form"].non_field_errors()
assert list(wrong_password_errors) == list(nonexistent_errors)Testing login_required
Every view decorated with @login_required needs two baseline tests: authenticated access works, and anonymous access is redirected. This is so consistent that it should be part of your standard test template for any new view.
import pytest
from django.urls import reverse
PROTECTED_URLS = [
("orders:order-list", {}),
("orders:order-detail", {"pk": 1}),
("accounts:profile", {}),
("accounts:settings", {}),
]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name,kwargs", PROTECTED_URLS)
def test_protected_views_redirect_anonymous_users(client, url_name, kwargs):
response = client.get(reverse(url_name, kwargs=kwargs))
assert response.status_code == 302
assert "/login/" in response["Location"]
@pytest.mark.django_db
@pytest.mark.parametrize("url_name,kwargs", [
("orders:order-list", {}),
("accounts:profile", {}),
("accounts:settings", {}),
])
def test_protected_views_allow_authenticated_users(authenticated_client, url_name, kwargs):
response = authenticated_client.get(reverse(url_name, kwargs=kwargs))
assert response.status_code == 200Using parametrize here means that adding a new protected URL to the list gives you coverage instantly, without writing a new test function. The test becomes a living registry of every protected route in the application.
Testing session authentication
Session authentication is Django's default for browser-based access. The session cookie is created on login and destroyed on logout. Testing it means verifying that the session state is correctly set and cleared.
@pytest.mark.django_db
def test_session_is_created_on_login(client, user):
client.post(reverse("login"), {
"username": "alice",
"password": "strongpassword123",
})
assert "_auth_user_id" in client.session
@pytest.mark.django_db
def test_session_contains_correct_user_id(client, user):
client.post(reverse("login"), {
"username": "alice",
"password": "strongpassword123",
})
assert client.session["_auth_user_id"] == str(user.pk)
@pytest.mark.django_db
def test_session_is_destroyed_on_logout(client, user):
client.force_login(user)
assert "_auth_user_id" in client.session
client.post(reverse("logout"))
assert "_auth_user_id" not in client.session
@pytest.mark.django_db
def test_inactive_user_cannot_log_in(client, user):
user.is_active = False
user.save()
response = client.post(reverse("login"), {
"username": "alice",
"password": "strongpassword123",
})
assert response.status_code == 200
assert "_auth_user_id" not in client.sessionTesting token authentication
Django REST Framework's TokenAuthentication issues a token per user. The client includes the token in the Authorization header on every request. Testing this means verifying that valid tokens grant access, missing tokens are rejected, and invalid tokens are rejected.
import pytest
from django.urls import reverse
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="testpass123")
@pytest.fixture
def token(user):
token, _ = Token.objects.get_or_create(user=user)
return token
@pytest.fixture
def token_client(token):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
return client
@pytest.mark.django_db
def test_valid_token_grants_access(token_client):
response = token_client.get(reverse("api:order-list"))
assert response.status_code == 200
@pytest.mark.django_db
def test_missing_token_returns_401(api_client):
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401
@pytest.mark.django_db
def test_invalid_token_returns_401(api_client):
api_client.credentials(HTTP_AUTHORIZATION="Token notarealtoken")
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401
@pytest.mark.django_db
def test_malformed_authorization_header_returns_401(api_client):
api_client.credentials(HTTP_AUTHORIZATION="Bearer notatoken")
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401
@pytest.mark.django_db
def test_token_obtain_endpoint_returns_token_for_valid_credentials(api_client, user):
response = api_client.post(reverse("api:token-obtain"), {
"username": "alice",
"password": "testpass123",
})
assert response.status_code == 200
assert "token" in response.data
@pytest.mark.django_db
def test_token_obtain_endpoint_rejects_wrong_password(api_client, user):
response = api_client.post(reverse("api:token-obtain"), {
"username": "alice",
"password": "wrongpassword",
})
assert response.status_code == 400
@pytest.mark.django_db
def test_token_for_inactive_user_returns_401(api_client, user, token):
user.is_active = False
user.save()
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401Testing JWT authentication
JSON Web Tokens (JWT) are stateless: the server issues a signed access token and a refresh token. The access token expires quickly. The refresh token is used to obtain new access tokens. Testing JWT means verifying the obtain, refresh, and verify flows, as well as expired and tampered token scenarios.
The most common Django JWT library is djangorestframework-simplejwt.
pip install djangorestframework-simplejwtimport pytest
from django.urls import reverse
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="testpass123")
@pytest.fixture
def jwt_tokens(api_client, user):
response = api_client.post(reverse("token_obtain_pair"), {
"username": "alice",
"password": "testpass123",
})
return response.data
@pytest.fixture
def jwt_client(jwt_tokens):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {jwt_tokens['access']}")
return client
@pytest.mark.django_db
def test_obtain_tokens_returns_access_and_refresh(api_client, user):
response = api_client.post(reverse("token_obtain_pair"), {
"username": "alice",
"password": "testpass123",
})
assert response.status_code == 200
assert "access" in response.data
assert "refresh" in response.data
@pytest.mark.django_db
def test_obtain_tokens_rejects_wrong_password(api_client, user):
response = api_client.post(reverse("token_obtain_pair"), {
"username": "alice",
"password": "wrongpassword",
})
assert response.status_code == 401
@pytest.mark.django_db
def test_valid_access_token_grants_access(jwt_client):
response = jwt_client.get(reverse("api:order-list"))
assert response.status_code == 200
@pytest.mark.django_db
def test_refresh_token_produces_new_access_token(api_client, jwt_tokens):
response = api_client.post(reverse("token_refresh"), {
"refresh": jwt_tokens["refresh"],
})
assert response.status_code == 200
assert "access" in response.data
assert response.data["access"] != jwt_tokens["access"]
@pytest.mark.django_db
def test_invalid_refresh_token_returns_401(api_client):
response = api_client.post(reverse("token_refresh"), {
"refresh": "notarealtoken",
})
assert response.status_code == 401
@pytest.mark.django_db
def test_tampered_access_token_returns_401(api_client):
api_client.credentials(HTTP_AUTHORIZATION="Bearer eyJ0.tampered.token")
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401
@pytest.mark.django_db
def test_missing_authorization_header_returns_401(api_client):
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401Custom permission classes
Custom DRF permission classes contain logic that determines whether a request should be allowed. They have two methods you need to test: has_permission (runs on every request) and has_object_permission (runs on object-level requests like retrieve, update, and delete).
You can unit test permission classes directly without making HTTP requests, or integration test them through the views they protect. Both approaches are valuable: unit tests for the logic, integration tests for the wiring.
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOwnerOrReadOnly(BasePermission):
"""
Allow read access to anyone. Allow write access only to the resource owner.
"""
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
return obj.customer.user == request.user
class IsLoyaltyMember(BasePermission):
"""
Restrict access to loyalty programme members.
"""
def has_permission(self, request, view):
return (
request.user.is_authenticated
and hasattr(request.user, "customer")
and request.user.customer.is_loyalty_member
)
class IsAdminOrOwner(BasePermission):
"""
Allow access to staff users and to the resource owner.
"""
def has_object_permission(self, request, view, obj):
return request.user.is_staff or obj.customer.user == request.userimport pytest
from unittest.mock import MagicMock
from rest_framework.test import APIRequestFactory
from django.contrib.auth import get_user_model
from orders.models import Customer, Order
from orders.permissions import IsOwnerOrReadOnly, IsLoyaltyMember, IsAdminOrOwner
User = get_user_model()
factory = APIRequestFactory()
# Unit tests for permission classes
@pytest.mark.django_db
def test_is_owner_or_read_only_allows_get_for_anyone(db):
user = User.objects.create_user(username="alice", password="pass")
other_user = User.objects.create_user(username="bob", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=user)
order = Order.objects.create(customer=customer)
request = factory.get("/")
request.user = other_user
permission = IsOwnerOrReadOnly()
assert permission.has_object_permission(request, None, order)
@pytest.mark.django_db
def test_is_owner_or_read_only_allows_post_for_owner(db):
user = User.objects.create_user(username="alice", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=user)
order = Order.objects.create(customer=customer)
request = factory.post("/")
request.user = user
permission = IsOwnerOrReadOnly()
assert permission.has_object_permission(request, None, order)
@pytest.mark.django_db
def test_is_owner_or_read_only_denies_post_for_non_owner(db):
user = User.objects.create_user(username="alice", password="pass")
other_user = User.objects.create_user(username="bob", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=user)
order = Order.objects.create(customer=customer)
request = factory.post("/")
request.user = other_user
permission = IsOwnerOrReadOnly()
assert not permission.has_object_permission(request, None, order)
@pytest.mark.django_db
def test_is_loyalty_member_allows_loyalty_members(db):
user = User.objects.create_user(username="alice", password="pass")
Customer.objects.create(
name="Alice", email="a@example.com", user=user, is_loyalty_member=True
)
request = factory.get("/")
request.user = user
permission = IsLoyaltyMember()
assert permission.has_permission(request, None)
@pytest.mark.django_db
def test_is_loyalty_member_denies_non_members(db):
user = User.objects.create_user(username="alice", password="pass")
Customer.objects.create(
name="Alice", email="a@example.com", user=user, is_loyalty_member=False
)
request = factory.get("/")
request.user = user
permission = IsLoyaltyMember()
assert not permission.has_permission(request, None)
@pytest.mark.django_db
def test_is_loyalty_member_denies_anonymous_user(db):
from django.contrib.auth.models import AnonymousUser
request = factory.get("/")
request.user = AnonymousUser()
permission = IsLoyaltyMember()
assert not permission.has_permission(request, None)
@pytest.mark.django_db
def test_is_admin_or_owner_allows_staff(db):
staff_user = User.objects.create_user(username="admin", password="pass", is_staff=True)
regular_user = User.objects.create_user(username="alice", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=regular_user)
order = Order.objects.create(customer=customer)
request = factory.get("/")
request.user = staff_user
permission = IsAdminOrOwner()
assert permission.has_object_permission(request, None, order)
@pytest.mark.django_db
def test_is_admin_or_owner_allows_owner(db):
user = User.objects.create_user(username="alice", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=user)
order = Order.objects.create(customer=customer)
request = factory.get("/")
request.user = user
permission = IsAdminOrOwner()
assert permission.has_object_permission(request, None, order)
@pytest.mark.django_db
def test_is_admin_or_owner_denies_non_owner_non_staff(db):
user = User.objects.create_user(username="alice", password="pass")
other_user = User.objects.create_user(username="bob", password="pass")
customer = Customer.objects.create(name="Alice", email="a@example.com", user=user)
order = Order.objects.create(customer=customer)
request = factory.get("/")
request.user = other_user
permission = IsAdminOrOwner()
assert not permission.has_object_permission(request, None, order)Unit testing permission classes with APIRequestFactory is much faster than testing through a full HTTP request. You build a minimal request object, set the user, and call the permission method directly. The test is precise: it only fails if the permission logic is wrong, not if the view has a different problem.
Object-level permissions
Object-level permissions are the most common source of data leakage bugs. They are easy to forget and easy to break during a refactor. Integration tests that verify the full request cycle are the strongest guarantee here, because they confirm not just that the permission class works in isolation but that the view is actually calling it.
# Integration tests for object-level permission wiring
@pytest.mark.django_db
def test_owner_can_update_their_order(authenticated_api_client, order):
response = authenticated_api_client.patch(
reverse("api:order-detail", kwargs={"pk": order.pk}),
data={"subtotal": "200.00"},
format="json",
)
assert response.status_code == 200
@pytest.mark.django_db
def test_non_owner_cannot_update_order(api_client, order, db):
other_user = User.objects.create_user(username="bob", password="pass")
api_client.force_authenticate(user=other_user)
response = api_client.patch(
reverse("api:order-detail", kwargs={"pk": order.pk}),
data={"subtotal": "200.00"},
format="json",
)
assert response.status_code in (403, 404)
@pytest.mark.django_db
def test_non_owner_update_does_not_change_order(api_client, order, db):
original_subtotal = order.subtotal
other_user = User.objects.create_user(username="bob", password="pass")
api_client.force_authenticate(user=other_user)
api_client.patch(
reverse("api:order-detail", kwargs={"pk": order.pk}),
data={"subtotal": "999.00"},
format="json",
)
order.refresh_from_db()
assert order.subtotal == original_subtotal⚠ Gotcha
Test both 403 and 404 for object-level denials
When a non-owner tries to access an object, the correct response depends on your view's queryset. If the queryset filters by owner (as it should), the object is invisible to the other user and you get 404. If the queryset is unfiltered and you rely only on has_object_permission, you get 403. Both are acceptable. The test should assert on whichever your view produces, and the database state assertion applies in either case.
Role-based access control
Role-based access control (RBAC) restricts features and data based on the user's role. In Django this is typically implemented with Django's built-in groups and permissions system, with custom logic layered on top for application-specific roles.
from rest_framework.permissions import BasePermission
class CanManageOrders(BasePermission):
"""
Allows access to users in the 'order_managers' group or staff users.
"""
def has_permission(self, request, view):
return (
request.user.is_authenticated
and (
request.user.is_staff
or request.user.groups.filter(name="order_managers").exists()
)
)import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.urls import reverse
from rest_framework.test import APIClient
User = get_user_model()
@pytest.fixture
def order_manager_group(db):
group, _ = Group.objects.get_or_create(name="order_managers")
return group
@pytest.fixture
def order_manager_user(db, order_manager_group):
user = User.objects.create_user(username="manager", password="pass")
user.groups.add(order_manager_group)
return user
@pytest.fixture
def regular_user(db):
return User.objects.create_user(username="regular", password="pass")
@pytest.fixture
def staff_user(db):
return User.objects.create_user(username="staff", password="pass", is_staff=True)
# Group membership
@pytest.mark.django_db
def test_order_manager_can_access_admin_endpoint(order_manager_user):
client = APIClient()
client.force_authenticate(user=order_manager_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 200
@pytest.mark.django_db
def test_regular_user_cannot_access_admin_endpoint(regular_user):
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 403
@pytest.mark.django_db
def test_staff_user_can_access_admin_endpoint(staff_user):
client = APIClient()
client.force_authenticate(user=staff_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 200
@pytest.mark.django_db
def test_anonymous_user_cannot_access_admin_endpoint():
client = APIClient()
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 401
# Group assignment and removal
@pytest.mark.django_db
def test_removing_user_from_group_revokes_access(order_manager_user, order_manager_group):
order_manager_user.groups.remove(order_manager_group)
client = APIClient()
client.force_authenticate(user=order_manager_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 403
@pytest.mark.django_db
def test_adding_user_to_group_grants_access(regular_user, order_manager_group):
regular_user.groups.add(order_manager_group)
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 200The last two tests are the most important ones in a RBAC test suite: they verify that group membership is dynamic. Adding a user to a group must grant access immediately. Removing them must revoke it. Systems that cache permissions can fail these tests, which is exactly the kind of bug you want to catch before it reaches production.
Staff and superuser access
Django has two elevated user types beyond ordinary users: staff (is_staff=True) and superusers (is_superuser=True). Staff users can log into the admin. Superusers bypass all permission checks in the admin and can be granted application-level bypass logic too.
If your application grants elevated access to staff or superusers, test every scenario where that elevation applies and every scenario where it should not:
@pytest.mark.django_db
def test_staff_can_access_staff_only_endpoint(staff_user):
client = APIClient()
client.force_authenticate(user=staff_user)
response = client.get(reverse("api:staff-dashboard"))
assert response.status_code == 200
@pytest.mark.django_db
def test_regular_user_cannot_access_staff_endpoint(regular_user):
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.get(reverse("api:staff-dashboard"))
assert response.status_code == 403
@pytest.mark.django_db
def test_superuser_bypasses_all_permission_checks(db):
superuser = User.objects.create_superuser(username="superadmin", password="pass")
client = APIClient()
client.force_authenticate(user=superuser)
response = client.get(reverse("api:staff-dashboard"))
assert response.status_code == 200
@pytest.mark.django_db
def test_staff_flag_alone_does_not_grant_object_ownership(staff_user, order):
client = APIClient()
client.force_authenticate(user=staff_user)
# staff_user does not own this order
response = client.patch(
reverse("api:order-detail", kwargs={"pk": order.pk}),
data={"subtotal": "999.00"},
format="json",
)
# should be 403 or 404 unless your permission explicitly grants staff write access
assert response.status_code in (403, 404)Testing permission denied responses
The HTTP status code for a permission denial depends on the authentication state of the request. DRF enforces this consistently:
- Unauthenticated request: 401 Unauthorized. The client has not identified itself.
- Authenticated request without permission: 403 Forbidden. The client is known but not allowed.
Returning 403 to an unauthenticated user reveals that the resource exists. Returning 401 invites the client to authenticate and retry. Always assert the correct status code for each scenario.
@pytest.mark.django_db
def test_unauthenticated_request_returns_401_not_403(api_client):
response = api_client.get(reverse("api:order-list"))
assert response.status_code == 401
@pytest.mark.django_db
def test_authenticated_user_without_permission_returns_403(regular_user):
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.get(reverse("api:admin-order-list"))
assert response.status_code == 403
@pytest.mark.django_db
def test_permission_denied_response_includes_detail_message(regular_user):
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.get(reverse("api:admin-order-list"))
assert "detail" in response.dataAuth fixtures that scale
As your application grows, you will have more user types: regular users, loyalty members, order managers, staff, and superusers. Defining a fixture for each one in conftest.py means every test in the suite can request any user type with a single parameter.
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from rest_framework.test import APIClient
from orders.models import Customer
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="testpass123")
@pytest.fixture
def other_user(db):
return User.objects.create_user(username="bob", password="testpass123")
@pytest.fixture
def staff_user(db):
return User.objects.create_user(
username="staffuser", password="testpass123", is_staff=True
)
@pytest.fixture
def superuser(db):
return User.objects.create_superuser(
username="superadmin", password="testpass123"
)
@pytest.fixture
def loyalty_customer(db, user):
return Customer.objects.create(
name="Alice", email="alice@example.com", user=user, is_loyalty_member=True
)
@pytest.fixture
def order_manager_user(db):
group, _ = Group.objects.get_or_create(name="order_managers")
user = User.objects.create_user(username="manager", password="testpass123")
user.groups.add(group)
return user
# API clients
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def staff_api_client(api_client, staff_user):
api_client.force_authenticate(user=staff_user)
return api_client
@pytest.fixture
def manager_api_client(api_client, order_manager_user):
api_client.force_authenticate(user=order_manager_user)
return api_client
# Browser clients
@pytest.fixture
def authenticated_client(client, user):
client.force_login(user)
return client
@pytest.fixture
def staff_client(client, staff_user):
client.force_login(staff_user)
return clientWith this fixture set in place, writing a permission test for any endpoint takes three lines: get the client, make the request, assert the status code. There is no setup boilerplate inside the test at all.
@pytest.mark.django_db
def test_manager_can_view_all_orders(manager_api_client):
response = manager_api_client.get(reverse("api:admin-order-list"))
assert response.status_code == 200
@pytest.mark.django_db
def test_regular_user_cannot_view_all_orders(authenticated_api_client):
response = authenticated_api_client.get(reverse("api:admin-order-list"))
assert response.status_code == 403
@pytest.mark.django_db
def test_anonymous_user_cannot_view_all_orders(api_client):
response = api_client.get(reverse("api:admin-order-list"))
assert response.status_code == 401✦ Tip
Write a permission matrix as a parametrized test
For endpoints with multiple user types, use parametrize to express the full permission matrix in one test. It reads like a table: each row is a user type and the expected status code. When you add a new user type, you add one row.
@pytest.mark.django_db
@pytest.mark.parametrize("fixture_name,expected_status", [
("authenticated_api_client", 403),
("staff_api_client", 200),
("manager_api_client", 200),
("api_client", 401),
])
def test_admin_order_list_permission_matrix(request, fixture_name, expected_status):
api_client = request.getfixturevalue(fixture_name)
response = api_client.get(reverse("api:admin-order-list"))
assert response.status_code == expected_statusrequest.getfixturevalue(name) is a pytest built-in that retrieves a fixture by name at runtime. This lets you parametrize over fixture names, which would not be possible by declaring them as parameters directly.
Summary
- Auth bugs are often invisible in normal usage and can sit dormant until a user or attacker finds the unguarded path. Every boundary needs an explicit test.
- Test login with valid credentials, wrong password, nonexistent user, and empty input. Assert that wrong-password and nonexistent-user produce the same error to prevent username enumeration.
- Test that inactive users cannot authenticate, regardless of whether they have a valid token.
- Test session creation and destruction explicitly by inspecting
client.session. - For token auth, test valid tokens, missing tokens, invalid tokens, and malformed Authorization headers as four separate cases.
- For JWT, test obtain, refresh, and verify flows. Test tampered tokens and expired tokens.
- Unit test permission classes with
APIRequestFactoryfor speed and precision. Integration test them through views to confirm the wiring is correct. - For object-level permissions, always assert on database state after a failed write to confirm the rejection was real and not a coincidence.
- For RBAC, test that adding and removing group membership grants and revokes access immediately.
- Unauthenticated requests should return 401. Authenticated requests without permission should return 403. These are different cases and need different tests.
- Define a fixture for every user type in
conftest.py. Use a permission matrix parametrized test to cover all user types against a single endpoint in one readable table.
In the next article we shift from testing existing code to writing code test-first. We will build a real Django feature using TDD: red, green, refactor, one cycle at a time.