'Django Rest Framework gives 302 in Unit tests when force_login() on detail view?

I'm using Django Rest Framework to serve an API. I've got a couple tests which work great. To do a post the user needs to be logged in and I also do some checks for the detail view for a logged in user. I do this as follows:

class DeviceTestCase(APITestCase):
    USERNAME = "username"
    EMAIL = '[email protected]'
    PASSWORD = "password"

    def setUp(self):
        self.sa_group, _ = Group.objects.get_or_create(name=settings.KEYCLOAK_SA_WRITE_PERMISSION_NAME)
        self.authorized_user = User.objects.create_user(self.USERNAME, self.EMAIL, self.PASSWORD)
        self.sa_group.user_set.add(self.authorized_user)

    def test_post(self):
        device = DeviceFactory.build()
        url = reverse('device-list')

        self.client.force_login(self.authorized_user)
        response = self.client.post(url, data={'some': 'test', 'data': 'here'}, format='json')
        self.client.logout()

        self.assertEqual(status.HTTP_201_CREATED, response.status_code)
        # And some more tests here

    def test_detail_logged_in(self):
        device = DeviceFactory.create()

        url = reverse('device-detail', kwargs={'pk': device.pk})

        self.client.force_login(self.authorized_user)
        response = self.client.get(url)
        self.client.logout()

        self.assertEqual(status.HTTP_200_OK, response.status_code, 'Wrong response code for {}'.format(url))
        # And some more tests here

The first test works great. It posts the new record and all checks pass. The second test fails though. It gives an error saying

AssertionError: 200 != 302 : Wrong response code for /sa/devices/1/

It turns out the list view redirects the user to the login screen. Why does the first test log the user in perfectly, but does the second test redirect the user to the login screen? Am I missing something?

Here is the view:

class APIAuthGroup(InAuthGroup):
    """
A permission to allow all GETS, but only allow a POST if a user is logged in,
and is a member of the slimme apparaten role inside keycloak.
    """
    allowed_group_names = [settings.KEYCLOAK_SA_WRITE_PERMISSION_NAME]

    def has_permission(self, request, view):
        return request.method in SAFE_METHODS \
               or super(APIAuthGroup, self).has_permission(request, view)


class DevicesViewSet(DatapuntViewSetWritable):
    """
    A view that will return the devices and makes it possible to post new ones
    """

    queryset = Device.objects.all().order_by('id')

    serializer_class = DeviceSerializer
    serializer_detail_class = DeviceSerializer

    http_method_names = ['post', 'list', 'get']

    permission_classes = [APIAuthGroup]


Solution 1:[1]

So i guess you have tested this and you get still the same result:

class APIAuthGroup(InAuthGroup):
    def has_permission(self, request, view):
        return True

Why do you use DeviceFactory.build() in the first test and DeviceFactory.create() in the second?

Maybe a merge of the two can help you:

def test_get(self):
    device = DeviceFactory.build()
    url = reverse('device-list')
    response = self.client.get(url)
    self.assertEqual(status.HTTP_200_OK, response.status_code)

Solution 2:[2]

Here is why you are getting this error.

Dependent Libraries

I did some searching by Class Names to find which libraries you were using so that I can re-create the problem on my machine. The library causing the problem is the one called keycloak_idc. This library installs another library mozilla_django_oidc which would turn out to be the reason you are getting this.

Why This Library Is Causing The Problem

Inside the README file of this library, it gives you instructions on how to set it up. These are found in this file. Inside these instructions, it instructed you to add the AUTHENTICATION_BACKENDS

AUTHENTICATION_BACKENDS = [
'keycloak_oidc.auth.OIDCAuthenticationBackend',
...
]

When you add this authentication backend, all your requests pass through a Middleware defined inside the SessionRefresh class defined inside mozilla_django_oidc/middleware.py. Inside this class, the method process_request() is always called.

The first thing this method does is call the is_refreshable_url() method which always returns False if the request method was POST. Otherwise (when the request method is GET), it will return True.

Now the body of this if condition was as follows.

        if not self.is_refreshable_url(request):
            LOGGER.debug('request is not refreshable')
            return
        # lots of stuff in here
        return HttpResponseRedirect(redirect_url)

Since this is a middleware, if the request was POST and the return was None, Django would just proceed with actually doing your request. However when the request is GET and the line return HttpResponseRedirect(redirect_url) is triggered instead, Django will not even proceed with calling your view and will return the 302 response immediately.

The Solution

After a couple of hours debugging this, I do not the exact logic behind this middleware or what exactly are you trying to do to provide a concrete solution since this all started based off guess-work but a naive fix can be that you remove the AUTHENTICATION_BACKENDS from your settings file. While I feel that this is not acceptable, maybe you can try using another library that accomplishes what you're trying to do or find an alternative way to do it. Also, maybe you can contact the author and see what they think.

Solution 3:[3]

Is this a problem with the setUp() method? From what I see, you may be setting self.authorize_user to a user that was already created on the first test.

Instead, I would create the user on each test, making sure that the user doesn't exist already, like so:

user_exists = User.objects.filter(username=self.USERNAME, email=self.EMAIL).exists()
if not user_exists:
    self.authorize_user = User.objects.create_user....

That would explain why your first test did pass, why your second didn't, and why @anupam-chaplot's answer didn't reproduce the error.

Solution 4:[4]

Your reasoning and code looks ok.

However you are not giving the full code, there must be error you are not seeing.

Suspicious fact

  1. It isn't be default 302 when you are not logged in. (@login_required, etc redirects but your code doesn't have it)
  2. Your APIAuthGroup permission does allow GET requests for non-logged-in user ( return request.method in SAFE_METHODS), and you are using GET requests (self.client.get(url))

So it means you are not hitting the endpoint that you think you are hitting (your get request is not hitting the DevicesViewSet method)

Or it could be the case you have some global permission / redirect related setting in your settings.py which could be DRF related.. eg :

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ]
}

Guess

url = reverse('device-detail', kwargs={'pk': device.pk}) 

might not point to the url you are thinking.. maybe there's another url (/sa/devices/1/) that overrides the viewset's url. (You might have a django view based url)

And I didn't address why you are getting redirected after force_login.

If it's indeed login related redirect, all I can think of is self.authorized_user.refresh_from_db() or refreshing the request ..

I guess some loggin related property (such as session, or request.user) might point to old instance .. (I have no evidence or fact this can happen, but just a hunch) and you better off not logging out/in for every test case)

Solution 5:[5]

You should make a seperate settings file for testing and add to the test command --settings=project_name.test_settings, that's how I was told to do.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Peter Emil
Solution 3 ohduran
Solution 4
Solution 5