Search code examples
djangodjango-rest-frameworkpytest-django

Request in test APIClient fails after authenticating twice


After upgrading to django 5, our test suite started showing odd behavior. We are using DRF's APIClient and authentication does not work as expected anymore. Certain API requests return rest_framework.exceptions.NotAuthenticated with status code 403 (by the way, if someone could explain why this status is 403 and not 401, I'd appreciate).

This is seemingly arbitrary. Among 2 requests to views which are implemented identically in terms of user permissions etc, one returns 403 during testing only. Executing the same calls in Postman does not reproduce the error. Thus, some of the exact same test cases which used to pass under django 4 do not pass anymore under django 5 due to above error. I did not manage to reproduce these cases in a minimal example.

What I did reproduce however, is the following:

client.force_authenticate(user)
client.get(url)  # the user does not have permissions. response is 403, as expected
client.force_authenticate(None)  # "log out"
user.user_permissions.add(permission)
client.force_authenticate(user)
response = client.get(url)  # the now has permissions. response 200 is expected
assert response.status_code == 200  # response status is 403

Package versions are

django==5.0.3
djangorestframework==3.15.0
pytest==7.4.3
pytest-django==4.7.0

I have made a minimal project where this can be observed.

As far as I managed to test it, this unexpected behavior occurs in both django 4 and 5. Am I using the test client incorrectly here?


Solution

  • This behavior is described in the docs:

    The ModelBackend caches permissions on the user object after the first time they need to be fetched for a permissions check. This is typically fine for the request-response cycle since permissions aren’t typically checked immediately after they are added (in the admin, for example). If you are adding permissions and checking them immediately afterward, in a test or view for example, the easiest solution is to re-fetch the user from the database.

    Permissions caching

    The solution:

    client.force_authenticate(user=user)
    client.get("/test/")
    client.force_authenticate(user=None)
    
    user.user_permissions.add(permission)
    # Getting a new user instance. 
    # user.refresh_from_db() does not clear cache
    user = get_object_or_404(User, pk=user.id)
    
    client.force_authenticate(user=user)
    response = client.get("/test/")
    
    assert response.status_code == 200 
    

    Concerning the response.status_code returned for unauthenticated request there is this relevant bit of information in DRF docs:

    When an unauthenticated request is denied permission there are two different error codes that may be appropriate.

    HTTP 401 Unauthorized

    HTTP 403 Permission Denied

    HTTP 401 responses must always include a WWW-Authenticate header, that instructs the client how to authenticate. HTTP 403 responses do not include the WWW-Authenticate header.

    The kind of response that will be used depends on the authentication scheme. Although multiple authentication schemes may be in use, only one scheme may be used to determine the type of response. The first authentication class set on the view is used when determining the type of response.

    If DEFAULT_AUTHENTICATION_CLASSES are not specified in settings, the default ones are used:

    [
        'rest_framework.authentication.SessionAuthentication',
        'rest_framework.authentication.BasicAuthentication'
    ]
    

    After disabling SessionAuthentication class (or changing the order of authentication classes, BasicAuthentication first), 401 responses are returning for unauthenticated requests.