mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
More tests for users
app (#7479)
* disable patch coverage * also test for is_active flag * ignore edge case regarding coverage * add tests for api logout * test login redirects * style fixes * fully utilise serializer for /api/user/roles api * ignore logout mig from cov * bump api version as we are now documenting the roles endpoint * ignore on migration runs for coverage * remove dead code * ignore potential caching errors for coverage * test default dj_rest_auth token endpoint * move pragma * just ignore whole block * move ignore back * test for token based token revocation
This commit is contained in:
parent
5a6708a042
commit
935243c2d5
@ -3,6 +3,7 @@ coverage:
|
|||||||
project:
|
project:
|
||||||
default:
|
default:
|
||||||
target: 82%
|
target: 82%
|
||||||
|
patch: off
|
||||||
|
|
||||||
github_checks:
|
github_checks:
|
||||||
annotations: true
|
annotations: true
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 207
|
INVENTREE_API_VERSION = 208
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
v208 - 2024-06-19 : https://github.com/inventree/InvenTree/pull/7479
|
||||||
|
- Adds documentation for the user roles API endpoint (no functional changes)
|
||||||
|
|
||||||
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
|
v207 - 2024-06-09 : https://github.com/inventree/InvenTree/pull/7420
|
||||||
- Moves all "Attachment" models into a single table
|
- Moves all "Attachment" models into a single table
|
||||||
- All "Attachment" operations are now performed at /api/attachment/
|
- All "Attachment" operations are now performed at /api/attachment/
|
||||||
|
@ -4,8 +4,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth import authenticate, get_user, login, logout
|
from django.contrib.auth import authenticate, get_user, login, logout
|
||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.db.models import Q
|
|
||||||
from django.http.response import HttpResponse
|
from django.http.response import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import include, path, re_path, reverse
|
from django.urls import include, path, re_path, reverse
|
||||||
@ -34,8 +33,8 @@ from InvenTree.mixins import (
|
|||||||
)
|
)
|
||||||
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
|
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
|
||||||
from InvenTree.settings import FRONTEND_URL_BASE
|
from InvenTree.settings import FRONTEND_URL_BASE
|
||||||
from users.models import ApiToken, Owner, RuleSet, check_user_role
|
from users.models import ApiToken, Owner
|
||||||
from users.serializers import GroupSerializer, OwnerSerializer
|
from users.serializers import GroupSerializer, OwnerSerializer, RoleSerializer
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
@ -113,63 +112,15 @@ class OwnerDetail(RetrieveAPI):
|
|||||||
serializer_class = OwnerSerializer
|
serializer_class = OwnerSerializer
|
||||||
|
|
||||||
|
|
||||||
class RoleDetails(APIView):
|
class RoleDetails(RetrieveAPI):
|
||||||
"""API endpoint which lists the available role permissions for the current user.
|
"""API endpoint which lists the available role permissions for the current user."""
|
||||||
|
|
||||||
(Requires authentication)
|
|
||||||
"""
|
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
serializer_class = None
|
serializer_class = RoleSerializer
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get_object(self):
|
||||||
"""Return the list of roles / permissions available to the current user."""
|
"""Overwritten to always return current user."""
|
||||||
user = request.user
|
return self.request.user
|
||||||
|
|
||||||
roles = {}
|
|
||||||
|
|
||||||
for ruleset in RuleSet.RULESET_CHOICES:
|
|
||||||
role, _text = ruleset
|
|
||||||
|
|
||||||
permissions = []
|
|
||||||
|
|
||||||
for permission in RuleSet.RULESET_PERMISSIONS:
|
|
||||||
if check_user_role(user, role, permission):
|
|
||||||
permissions.append(permission)
|
|
||||||
|
|
||||||
if len(permissions) > 0:
|
|
||||||
roles[role] = permissions
|
|
||||||
else:
|
|
||||||
roles[role] = None # pragma: no cover
|
|
||||||
|
|
||||||
# Extract individual permissions for the user
|
|
||||||
if user.is_superuser:
|
|
||||||
permissions = Permission.objects.all()
|
|
||||||
else:
|
|
||||||
permissions = Permission.objects.filter(
|
|
||||||
Q(user=user) | Q(group__user=user)
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
perms = {}
|
|
||||||
|
|
||||||
for permission in permissions:
|
|
||||||
perm, model = permission.codename.split('_')
|
|
||||||
|
|
||||||
if model not in perms:
|
|
||||||
perms[model] = []
|
|
||||||
|
|
||||||
perms[model].append(perm)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'user': user.pk,
|
|
||||||
'username': user.username,
|
|
||||||
'roles': roles,
|
|
||||||
'permissions': perms,
|
|
||||||
'is_staff': user.is_staff,
|
|
||||||
'is_superuser': user.is_superuser,
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(data)
|
|
||||||
|
|
||||||
|
|
||||||
class UserDetail(RetrieveUpdateDestroyAPI):
|
class UserDetail(RetrieveUpdateDestroyAPI):
|
||||||
@ -312,7 +263,7 @@ class Logout(LogoutView):
|
|||||||
try:
|
try:
|
||||||
token = ApiToken.objects.get(key=token_key, user=request.user)
|
token = ApiToken.objects.get(key=token_key, user=request.user)
|
||||||
token.delete()
|
token.delete()
|
||||||
except ApiToken.DoesNotExist:
|
except ApiToken.DoesNotExist: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return super().logout(request)
|
return super().logout(request)
|
||||||
|
@ -26,7 +26,7 @@ class UsersConfig(AppConfig):
|
|||||||
|
|
||||||
# Skip if running migrations
|
# Skip if running migrations
|
||||||
if InvenTree.ready.isRunningMigrations():
|
if InvenTree.ready.isRunningMigrations():
|
||||||
return
|
return # pragma: no cover
|
||||||
|
|
||||||
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
if InvenTree.ready.canAppAccessDatabase(allow_test=True):
|
||||||
try:
|
try:
|
||||||
|
@ -6,7 +6,7 @@ from django.conf import settings
|
|||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
def clear_sessions(apps, schema_editor):
|
def clear_sessions(apps, schema_editor): # pragma: no cover
|
||||||
"""Clear all user sessions."""
|
"""Clear all user sessions."""
|
||||||
|
|
||||||
# Ignore in test mode
|
# Ignore in test mode
|
||||||
@ -20,6 +20,7 @@ def clear_sessions(apps, schema_editor):
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Database may not be ready yet, so this does not matter anyhow
|
# Database may not be ready yet, so this does not matter anyhow
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
atomic = False
|
atomic = False
|
||||||
|
@ -678,25 +678,6 @@ def clear_user_role_cache(user):
|
|||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
|
|
||||||
|
|
||||||
def get_user_roles(user):
|
|
||||||
"""Return all roles available to a given user."""
|
|
||||||
roles = set()
|
|
||||||
|
|
||||||
for group in user.groups.all():
|
|
||||||
for rule in group.rule_sets.all():
|
|
||||||
name = rule.name
|
|
||||||
if rule.can_view:
|
|
||||||
roles.add(f'{name}.view')
|
|
||||||
if rule.can_add:
|
|
||||||
roles.add(f'{name}.add')
|
|
||||||
if rule.can_change:
|
|
||||||
roles.add(f'{name}.change')
|
|
||||||
if rule.can_delete:
|
|
||||||
roles.add(f'{name}.delete')
|
|
||||||
|
|
||||||
return roles
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_role(user, role, permission):
|
def check_user_role(user, role, permission):
|
||||||
"""Check if a user has a particular role:permission combination.
|
"""Check if a user has a particular role:permission combination.
|
||||||
|
|
||||||
@ -710,7 +691,7 @@ def check_user_role(user, role, permission):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = cache.get(key)
|
result = cache.get(key)
|
||||||
except Exception:
|
except Exception: # pragma: no cover
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
@ -741,7 +722,7 @@ def check_user_role(user, role, permission):
|
|||||||
# Save result to cache
|
# Save result to cache
|
||||||
try:
|
try:
|
||||||
cache.set(key, result, timeout=3600)
|
cache.set(key, result, timeout=3600)
|
||||||
except Exception:
|
except Exception: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""DRF API serializers for the 'users' app."""
|
"""DRF API serializers for the 'users' app."""
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
@ -40,16 +41,21 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
"""Metaclass options."""
|
"""Metaclass options."""
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
fields = ['user', 'username', 'is_staff', 'is_superuser', 'roles']
|
fields = [
|
||||||
|
'user',
|
||||||
|
'username',
|
||||||
|
'roles',
|
||||||
|
'permissions',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
]
|
||||||
|
|
||||||
user = serializers.IntegerField(source='pk')
|
user = serializers.IntegerField(source='pk')
|
||||||
username = serializers.CharField()
|
|
||||||
is_staff = serializers.BooleanField()
|
|
||||||
is_superuser = serializers.BooleanField()
|
|
||||||
roles = serializers.SerializerMethodField()
|
roles = serializers.SerializerMethodField()
|
||||||
|
permissions = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_roles(self, user: User) -> dict:
|
def get_roles(self, user: User) -> dict:
|
||||||
"""Return roles associated with the specified User."""
|
"""Roles associated with the user."""
|
||||||
roles = {}
|
roles = {}
|
||||||
|
|
||||||
for ruleset in RuleSet.RULESET_CHOICES:
|
for ruleset in RuleSet.RULESET_CHOICES:
|
||||||
@ -67,3 +73,24 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
roles[role] = None # pragma: no cover
|
roles[role] = None # pragma: no cover
|
||||||
|
|
||||||
return roles
|
return roles
|
||||||
|
|
||||||
|
def get_permissions(self, user: User) -> dict:
|
||||||
|
"""Permissions associated with the user."""
|
||||||
|
if user.is_superuser:
|
||||||
|
permissions = Permission.objects.all()
|
||||||
|
else:
|
||||||
|
permissions = Permission.objects.filter(
|
||||||
|
Q(user=user) | Q(group__user=user)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
perms = {}
|
||||||
|
|
||||||
|
for permission in permissions:
|
||||||
|
perm, model = permission.codename.split('_')
|
||||||
|
|
||||||
|
if model not in perms:
|
||||||
|
perms[model] = []
|
||||||
|
|
||||||
|
perms[model].append(perm)
|
||||||
|
|
||||||
|
return perms
|
||||||
|
@ -48,6 +48,25 @@ class UserAPITests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertIn('name', response.data)
|
self.assertIn('name', response.data)
|
||||||
|
|
||||||
|
def test_logout(self):
|
||||||
|
"""Test api logout endpoint."""
|
||||||
|
token_key = self.get(url=reverse('api-token')).data['token']
|
||||||
|
self.client.logout()
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token_key)
|
||||||
|
|
||||||
|
self.post(reverse('api-logout'), expected_code=200)
|
||||||
|
self.get(reverse('api-token'), expected_code=401)
|
||||||
|
|
||||||
|
def test_login_redirect(self):
|
||||||
|
"""Test login redirect endpoint."""
|
||||||
|
response = self.get(reverse('api-login-redirect'), expected_code=302)
|
||||||
|
self.assertEqual(response.url, '/index/')
|
||||||
|
|
||||||
|
# PUI
|
||||||
|
self.put(reverse('api-ui-preference'), {'preferred_method': 'pui'})
|
||||||
|
response = self.get(reverse('api-login-redirect'), expected_code=302)
|
||||||
|
self.assertEqual(response.url, '/platform/logged-in/')
|
||||||
|
|
||||||
|
|
||||||
class UserTokenTests(InvenTreeAPITestCase):
|
class UserTokenTests(InvenTreeAPITestCase):
|
||||||
"""Tests for user token functionality."""
|
"""Tests for user token functionality."""
|
||||||
@ -156,3 +175,13 @@ class UserTokenTests(InvenTreeAPITestCase):
|
|||||||
token.save()
|
token.save()
|
||||||
|
|
||||||
self.client.get(me, expected_code=200)
|
self.client.get(me, expected_code=200)
|
||||||
|
|
||||||
|
def test_buildin_token(self):
|
||||||
|
"""Test the built-in token authentication."""
|
||||||
|
response = self.post(
|
||||||
|
reverse('rest_login'),
|
||||||
|
{'username': self.username, 'password': self.password},
|
||||||
|
expected_code=200,
|
||||||
|
)
|
||||||
|
self.assertIn('key', response.data)
|
||||||
|
self.assertTrue(response.data['key'].startswith('inv-'))
|
||||||
|
@ -221,6 +221,10 @@ class OwnerModelTest(InvenTreeTestCase):
|
|||||||
self.client.login(username=self.username, password=self.password)
|
self.client.login(username=self.username, password=self.password)
|
||||||
# user list
|
# user list
|
||||||
self.do_request(reverse('api-owner-list'), {})
|
self.do_request(reverse('api-owner-list'), {})
|
||||||
|
|
||||||
|
# user list with 'is_active' filter
|
||||||
|
self.do_request(reverse('api-owner-list'), {'is_active': False})
|
||||||
|
|
||||||
# user list with search
|
# user list with search
|
||||||
self.do_request(reverse('api-owner-list'), {'search': 'user'})
|
self.do_request(reverse('api-owner-list'), {'search': 'user'})
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user