django-rest-frameworkdjango-testingdjango-testsdjango-rest-framework-simplejwtdjango-rest-framework-permissions

DRF post request test failing because a custom permission is stating that the "owner_id" field (custom field) does not match the authenticated user id


I'm starting to write tests for my endpoint, "categories/", and I can't get past a custom permission that I have added to the view that is being tested. In this permission, it checks the authenticated user's id with that of the 'owner_id' field that is present in the payload that gets sent with the post request. If they do not match, the permission will deny access to the view. I wrote this permission to stop rogue requests (not through the front-end) from adding categories to the wrong user. Users are considered authenticated with a jwt token that is part of the post requests headers. In theory, Django should see the jwt, get the authenticated user from the database, and then at some point my permission should see that owner_id field is equal to the retrieved user's id. Perhaps I'm missing something with how created test users and authentication works? I have no problem successfully creating a category with an authenticated user with Postman. Please let me know if you need more information.

Failed Test Result:

FAIL: test_create_category (categories.tests.test_categories_api.CategoryAPIViewTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/nwmus/units/backend/categories/tests/test_categories_api.py", line 35, in test_create_category
    self.assertEqual(response.status_code, status.HTTP_201_CREATED)
AssertionError: 403 != 201

Test:

class CategoryAPIViewTests(APITestCase):
    categories_url = reverse("units_api:categories:category-list-create")

    def setUp(self):
        self.user = UnitsUser.objects.create_user(
            email="testuser@units.com", username="testuser", password="testpassword"
        )
        self.access_token = RefreshToken.for_user(self.user).access_token
        self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.access_token}")

    def test_create_category(self):
        self.client.force_authenticate(user=self.user)
        data = {
            "name": "test category",
            "description": "test description",
            "owner_id": self.user.id,
        }
        logger.debug(f"user: {self.user}")
        logger.debug(f"access_token: {self.access_token}")
        response = self.client.post(self.categories_url, data=data)
        logger.debug(f"data: {data}")
        logger.debug(f"response.data: {response.data}")
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data["name"], data["name"])
        self.assertEqual(response.data["description"], data["description"])
        self.assertEqual(response.data["owner_id"], self.user.id)

I have logged out some of the data points that I thought were necessary in the test case.

DEBUG user: id: 1, username: testuser, email: testuser@units.com
DEBUG access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzAyNjQ1NzczLCJpYXQiOjE3MDI2Mjc3NzMsImp0aSI6IjdjMWY5ZjdmYTI4NDRkMWJiMTEwYjg3ZjQxZjkxMDgzIiwidXNlcl9pZCI6MX0.7vwhvgRuclaVjJxWZ0nRLXgK4OI-fOg5G8AZwagFwY8
DEBUG data: {'name': 'test category', 'description': 'test description', 'owner_id': 1}
DEBUG response.data: {'detail': ErrorDetail(string='owner_id field does not match authenticated user', code='permission_denied')}

decoded access_token payload:

{
  "token_type": "access",
  "exp": 1702644373,
  "iat": 1702626373,
  "jti": "4f3fc61c5a174907923dd473dbb511d1",
  "user_id": 1
}

As you can see, the payload field, "user_id", the user id, and the data field, "owner_id", all match. I have no idea where to go from here. Perhaps my permission is flawed and there is a better way to do it?

Permission:

class UserIsOwnerPermission(permissions.BasePermission):
    """Permission that checks if authenticated user is the owner of the object being requested or created"""

    def has_object_permission(self, request, view, obj):
        return obj.owner_id == request.user

    def has_permission(self, request, view):
        # Catch POST requests that have an owner_id that does not match the authenticated user
        if "owner_id" in request.data:
            self.message = "owner_id field does not match authenticated user"
            return request.user.id == request.data["owner_id"]
        return True

View:

class CategoryListCreateView(UserIsOwnerMixin, generics.ListCreateAPIView):
    queryset = Category.objects.all()
    serializer_class = CategorySerializer
    permission_classes = [UserIsOwnerPermission]

Category Model:

class Category(models.Model):
    name = models.CharField(max_length=50)
    description = models.TextField(blank=True, null=True)
    color_hexcode = models.CharField(max_length=7, blank=True, null=True)
    owner_id = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, null=True)
    created_at = models.DateTimeField(default=timezone.now)

    class Meta:
        ordering = ("-created_at",)
        
    def __str__(self):
        return self.name

User Model

class CustomUnitsUserManager(BaseUserManager):
    def create_user(self, email, username, password, **extra_fields):
        if not email:
            raise ValueError(_("The Email must be set"))

        if not username:
            raise ValueError(_("The Username must be set"))

        email = self.normalize_email(email)
        user = self.model(email=email, username=username, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email, username, password, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError(_("Superuser must have is_staff=True."))
        if extra_fields.get("is_superuser") is not True:
            raise ValueError(_("Superuser must have is_superuser=True."))

        return self.create_user(email, username, password, **extra_fields)


class UnitsUser(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(_("email address"), unique=True)
    username = models.CharField(max_length=50, unique=True)
    first_name = models.CharField(max_length=50, blank=True)
    last_name = models.CharField(max_length=50, blank=True)
    date_joined = models.DateTimeField(default=timezone.now)
    about = models.TextField(_("about"), blank=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)

    objects = CustomUnitsUserManager()

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]

    def __str__(self):
        return f"id: {self.id}, username: {self.username}, email: {self.email}"

Solution

  • It looks like you are trying to match string '1' with integer 1, so that's why it fails

    class UserIsOwnerPermission(permissions.BasePermission):
        """Permission that checks if authenticated user is the owner of the object being requested or created"""
    
        def has_object_permission(self, request, view, obj):
            return obj.owner_id == request.user.id # not related, but here you missed the .id
    
        def has_permission(self, request, view):
            # Catch POST requests that have an owner_id that does not match the authenticated user
            if "owner_id" in request.data:
                self.message = "owner_id field does not match authenticated user"
                return str(request.user.id) == str(request.data["owner_id"])
            return True
    

    If your api should work with integer, just change the test

        def test_create_category(self):
            self.client.force_authenticate(user=self.user)
            data = {
                "name": "test category",
                "description": "test description",
                "owner_id": self.user.id,
            }