From 76b5cfcca21e2f81c94377f6de300ca59943cb75 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 8 Apr 2026 08:19:39 +1000 Subject: [PATCH] Merge commit from fork * Ensure the MeUserSerializer correctly marks fields as read-only * Bump API version * Add unit tests for the "me" endpoint * Additional unit tests * Add OPTIONS test --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/users/serializers.py | 25 +++++ src/backend/InvenTree/users/test_api.py | 106 +++++++++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index eddaf22bed..9ee1c1b7b3 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 471 +INVENTREE_API_VERSION = 472 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v472 -> 2026-04-01 : https://github.com/inventree/InvenTree/pull/xxxx + - Fixes writable fields on the user detail endpoint + v471 -> 2026-04-07 : https://github.com/inventree/InvenTree/pull/11685 - Adds data importer support for the "SalesOrderShipment" model diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 5882121665..0f58ebabb9 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -393,6 +393,9 @@ class MeUserSerializer(ExtendedUserSerializer): but ensures that certain fields are read-only. """ + # Remove the 'group_ids' field, as this is not relevant for the 'me' endpoint + fields = [f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids'] + read_only_fields = [ *ExtendedUserSerializer.Meta.read_only_fields, 'is_active', @@ -402,6 +405,28 @@ class MeUserSerializer(ExtendedUserSerializer): profile = UserProfileSerializer(many=False, read_only=True) + # Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only + is_staff = serializers.BooleanField( + label=_('Staff'), + help_text=_('Does this user have staff permissions'), + required=False, + read_only=True, + ) + + is_superuser = serializers.BooleanField( + label=_('Superuser'), + help_text=_('Is this user a superuser'), + required=False, + read_only=True, + ) + + is_active = serializers.BooleanField( + label=_('Active'), + help_text=_('Is this user account active'), + required=False, + read_only=True, + ) + def make_random_password(length=14): """Generate a random password of given length.""" diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index 016070d5ef..bf42986c87 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -44,7 +44,7 @@ class UserAPITests(InvenTreeAPITestCase): ) def test_api_url(self): - """Test the 'api_url attribute in related API endpoints. + """Test the 'api_url' attribute in related API endpoints. Ref: https://github.com/inventree/InvenTree/pull/10182 """ @@ -129,6 +129,19 @@ class UserAPITests(InvenTreeAPITestCase): self.assertIn('Only a superuser can adjust this field', str(response.data)) + # Try again, but with superuser access + self.user.is_superuser = True + self.user.save() + + response = self.post( + url, + data={**data, 'username': 'Superuser', 'is_superuser': True}, + expected_code=201, + ) + + self.assertEqual(response.data['username'], 'Superuser') + self.assertEqual(response.data['is_superuser'], True) + def test_user_detail(self): """Test the UserDetail API endpoint.""" user = User.objects.first() @@ -143,7 +156,7 @@ class UserAPITests(InvenTreeAPITestCase): self.get(url, expected_code=200) # Let's try to update the user - data = {'is_active': False, 'is_staff': False} + data = {'is_active': True, 'is_staff': False} self.patch(url, data=data, expected_code=403) @@ -158,6 +171,26 @@ class UserAPITests(InvenTreeAPITestCase): self.patch(url, data=data, expected_code=200) + # Try to change the "is_superuser" field - only a superuser can do this + data['is_superuser'] = True + response = self.patch(url, data=data, expected_code=403) + + self.assertIn( + 'You do not have permission to perform this action', str(response.data) + ) + + self.user.is_staff = True + self.user.is_superuser = True + self.user.save() + + for val in [True, False]: + data['is_staff'] = True + data['is_superuser'] = val + + response = self.patch(url, data=data, expected_code=200) + self.assertEqual(response.data['is_superuser'], val) + self.assertEqual(response.data['is_staff'], True) + # Try again, but logged out - expect no access to the endpoint self.logout() self.get(url, expected_code=401) @@ -229,6 +262,71 @@ class UserAPITests(InvenTreeAPITestCase): self.assertEqual(len(data['permissions']), len(perms) + len(build_perms)) + def test_me_endpoint(self): + """Test against the users /me/ endpoint.""" + url = reverse('api-user-me') + + # Test endpoint options + response = self.options(url, expected_code=200) + + # Check that particular fields are present, and have the correct attributes + fields = response.data['actions']['PUT'] + + for name in [ + 'pk', + 'username', + 'email', + 'groups', + 'is_active', + 'is_staff', + 'is_superuser', + ]: + self.assertIn(name, fields) + + for name in ['is_active', 'is_staff', 'is_superuser']: + self.assertTrue(fields[name]['read_only']) + + # Perform a GET request against the endpoint + response = self.get(url, expected_code=200) + + for field in [ + 'pk', + 'username', + 'email', + 'is_active', + 'is_staff', + 'is_superuser', + ]: + self.assertIn(field, response.data) + + # Change their own username + for name in ['Henry', 'Sally']: + response = self.patch(url, data={'username': name}, expected_code=200) + self.assertEqual(response.data['username'], name) + + # Defined starting point for the user + for v in [True, False]: + self.user.is_staff = v + self.user.is_superuser = v + self.user.save() + + for key in ['is_staff', 'is_superuser']: + for val in [True, False]: + response = self.patch(url, data={key: val}, expected_code=200) + + # Check that the field was *NOT CHANGED* + self.assertEqual(response.data[key], v) + + # Ensure we cannot change the "is_active" field either + response = self.patch(url, data={'is_active': False}, expected_code=200) + self.assertEqual(response.data['is_active'], True) + + self.user.is_active = False + self.user.save() + + # User cannot fetch their own details if they are not active + response = self.get(url, expected_code=401) + class SuperuserAPITests(InvenTreeAPITestCase): """Tests for user API endpoints that require superuser rights.""" @@ -245,7 +343,7 @@ class SuperuserAPITests(InvenTreeAPITestCase): resp = self.put(url, {'password': 1}, expected_code=400) self.assertContains(resp, 'This password is too short', status_code=400) - # now with overwerite + # now with overwrite resp = self.put( url, {'password': 1, 'override_warning': True}, expected_code=200 ) @@ -422,7 +520,7 @@ class UserTokenTests(InvenTreeAPITestCase): self.assertEqual(ApiToken.objects.count(), 1) -class GroupDetialTests(InvenTreeAPITestCase): +class GroupDetailTests(InvenTreeAPITestCase): """Tests for the GroupDetail API endpoint.""" fixtures = ['users']