From 8f40162974af188761d405e581130bce918f0e9d Mon Sep 17 00:00:00 2001 From: mpeltriaux Date: Thu, 27 Jan 2022 14:48:42 +0100 Subject: [PATCH] #31 API Tests * writes test for sharing using the API * fixes bug on frontend form where an exception occured on generating a new API token if no token existed, yet * adds permission constraint (default group) for using the api in general * fixes default-group-only behaviour for sharing-API, so users can only add new users and not removing them, as long as they do not have any other group membership like registration or conservation office * changes 'ksptoken' to 'Ksptoken' to match CGI standard for http header keys --- api/settings.py | 2 +- api/tests/test_api.py | 3 - api/tests/v1/__init__.py | 7 ++ api/tests/v1/test_api_sharing.py | 150 +++++++++++++++++++++++++++++++ api/views/views.py | 12 +++ konova/tests/test_views.py | 26 ++++++ user/forms.py | 3 +- user/templates/user/token.html | 4 +- 8 files changed, 201 insertions(+), 6 deletions(-) delete mode 100644 api/tests/test_api.py create mode 100644 api/tests/v1/__init__.py create mode 100644 api/tests/v1/test_api_sharing.py diff --git a/api/settings.py b/api/settings.py index d580cb8..ad7afef 100644 --- a/api/settings.py +++ b/api/settings.py @@ -5,4 +5,4 @@ Contact: michel.peltriaux@sgdnord.rlp.de Created on: 21.01.22 """ -KSP_TOKEN_HEADER_IDENTIFIER = "ksptoken" \ No newline at end of file +KSP_TOKEN_HEADER_IDENTIFIER = "Ksptoken" \ No newline at end of file diff --git a/api/tests/test_api.py b/api/tests/test_api.py deleted file mode 100644 index 7ce503c..0000000 --- a/api/tests/test_api.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/api/tests/v1/__init__.py b/api/tests/v1/__init__.py new file mode 100644 index 0000000..c5acaba --- /dev/null +++ b/api/tests/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Author: Michel Peltriaux +Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany +Contact: michel.peltriaux@sgdnord.rlp.de +Created on: 27.01.22 + +""" diff --git a/api/tests/v1/test_api_sharing.py b/api/tests/v1/test_api_sharing.py new file mode 100644 index 0000000..df673e0 --- /dev/null +++ b/api/tests/v1/test_api_sharing.py @@ -0,0 +1,150 @@ +import json + +from django.urls import reverse + +from konova.settings import DEFAULT_GROUP +from konova.tests.test_views import BaseTestCase +from konova.utils.user_checks import is_default_group_only + + +class APIV1SharingTestCase(BaseTestCase): + + @classmethod + def setUpTestData(cls): + """ Creates a test file in memory for using in further tests + + Returns: + + """ + super().setUpTestData() + cls.superuser.get_API_token() + cls.superuser.api_token.is_active = True + cls.superuser.api_token.save() + default_group = cls.groups.get(name=DEFAULT_GROUP) + cls.superuser.groups.add(default_group) + + cls.header_data = { + "HTTP_ksptoken": cls.superuser.api_token.token, + } + + def _run_share_request(self, url, user_list: list): + data = { + "users": user_list + } + data = json.dumps(data) + response = self.client.put( + url, + data, + **self.header_data + ) + return response + + def _test_api_sharing(self, obj, url): + """ Generic test for testing sharing of a ShareableObjectMixin object + + Args: + obj (ShareableObjectMixin): The object + url (str): The url to be used for a request + + Returns: + + """ + self.assertEqual(obj.users.count(), 0) + user_list = [ + self.superuser.username, + self.user.username, + ] + + response = self._run_share_request(url, user_list) + + # Must fail, since performing user has no access on requested object + self.assertEqual(response.status_code, 500) + self.assertTrue(len(json.loads(response.content.decode("utf-8")).get("errors", [])) > 0) + + # Add performing user to shared access users and rerun the request + obj.users.add(self.superuser) + response = self._run_share_request(url, user_list) + + shared_users = obj.shared_users + self.assertEqual(response.status_code, 200) + self.assertEqual(shared_users.count(), 2) + self.assertIn(self.superuser, shared_users) + self.assertIn(self.user, shared_users) + + def test_api_token_invalid(self): + """ Tests that a request with an invalid token won't be successfull + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + # Expect the first request to work properly + self.intervention.users.add(self.superuser) + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 200) + + # Change the token + self.header_data["HTTP_ksptoken"] = f"{self.superuser.api_token.token}__X" + + # Expect the request to fail now + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 403) + + def test_api_intervention_sharing(self): + """ Tests proper sharing of intervention + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + self._test_api_sharing(self.intervention, share_url) + + def test_api_eco_account_sharing(self): + """ Tests proper sharing of eco account + + Returns: + + """ + share_url = reverse("api:v1:ecoaccount-share", args=(self.eco_account.id,)) + self._test_api_sharing(self.eco_account, share_url) + + def test_api_ema_sharing(self): + """ Tests proper sharing of ema + + Returns: + + """ + share_url = reverse("api:v1:ema-share", args=(self.ema.id,)) + self._test_api_sharing(self.ema, share_url) + + def test_api_sharing_as_default_group_only(self): + """ Tests that sharing using the API as an only default group user works as expected. + + Expected: + Default only user can only add new users, having shared access. Removing them from the list of users + having shared access is only possible if the user has further rights, e.g. being part of a registration + or conservation office group. + + Returns: + + """ + share_url = reverse("api:v1:intervention-share", args=(self.intervention.id,)) + + # Give the user only default group rights + default_group = self.groups.get(name=DEFAULT_GROUP) + self.superuser.groups.set([default_group]) + self.assertTrue(is_default_group_only(self.superuser)) + + # Add only him as shared_users an object + self.intervention.users.set([self.superuser]) + self.assertEqual(self.intervention.users.count(), 1) + + # Try to add another user via API -> must work! + response = self._run_share_request(share_url, [self.superuser.username, self.user.username]) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.intervention.users.count(), 2) + + # Now try to remove the user again -> expect no changes at all to the shared user list + response = self._run_share_request(share_url, [self.superuser.username]) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.intervention.users.count(), 2) diff --git a/api/views/views.py b/api/views/views.py index 8cbdc1e..4e0899f 100644 --- a/api/views/views.py +++ b/api/views/views.py @@ -18,6 +18,7 @@ from compensation.models import EcoAccount from ema.models import Ema from intervention.models import Intervention from konova.utils.message_templates import DATA_UNSHARED +from konova.utils.user_checks import is_default_group_only from user.models import User @@ -39,6 +40,8 @@ class AbstractAPIView(View): try: # Fetch the proper user from the given request header token self.user = APIUserToken.get_user_from_token(request.headers.get(KSP_TOKEN_HEADER_IDENTIFIER, None)) + if not self.user.is_default_user(): + raise PermissionError("Default permissions required") except PermissionError as e: return self.return_error_response(e, 403) return super().dispatch(request, *args, **kwargs) @@ -240,6 +243,15 @@ class AbstractModelShareAPIView(AbstractAPIView): new_users_objs = [] for user in new_users: new_users_objs.append(User.objects.get(username=user)) + + if is_default_group_only(self.user): + # Default only users are not allowed to remove other users from having access. They can only add new ones! + new_users_to_be_added = User.objects.filter( + username__in=new_users + ).exclude( + id__in=obj.shared_users + ) + new_users_objs = obj.shared_users.union(new_users_to_be_added) obj.share_with_list(new_users_objs) return True diff --git a/konova/tests/test_views.py b/konova/tests/test_views.py index 1e0ae7f..9ec8bd8 100644 --- a/konova/tests/test_views.py +++ b/konova/tests/test_views.py @@ -7,6 +7,7 @@ Created on: 26.10.21 """ import datetime +from ema.models import Ema from user.models import User from django.contrib.auth.models import Group from django.contrib.gis.geos import MultiPolygon, Polygon @@ -52,6 +53,7 @@ class BaseTestCase(TestCase): cls.intervention = cls.create_dummy_intervention() cls.compensation = cls.create_dummy_compensation() cls.eco_account = cls.create_dummy_eco_account() + cls.ema = cls.create_dummy_ema() cls.create_dummy_states() cls.create_dummy_action() cls.codes = cls.create_dummy_codes() @@ -168,6 +170,30 @@ class BaseTestCase(TestCase): ) return eco_account + @classmethod + def create_dummy_ema(cls): + """ Creates an ema which can be used for tests + + Returns: + + """ + # Create dummy data + # Create log entry + action = UserActionLogEntry.get_created_action(cls.superuser) + geometry = Geometry.objects.create() + # Create responsible data object + responsible_data = Responsibility.objects.create() + # Finally create main object, holding the other objects + ema = Ema.objects.create( + identifier="TEST", + title="Test_title", + responsible=responsible_data, + created=action, + geometry=geometry, + comment="Test", + ) + return ema + @classmethod def create_dummy_states(cls): """ Creates an intervention which can be used for tests diff --git a/user/forms.py b/user/forms.py index 371277e..fd66a91 100644 --- a/user/forms.py +++ b/user/forms.py @@ -152,7 +152,8 @@ class UserAPITokenForm(BaseForm): """ user = self.instance new_token = self.cleaned_data["token"] - user.api_token.delete() + if user.api_token is not None: + user.api_token.delete() new_token = APIUserToken.objects.create( token=new_token ) diff --git a/user/templates/user/token.html b/user/templates/user/token.html index ba88e5e..47b873d 100644 --- a/user/templates/user/token.html +++ b/user/templates/user/token.html @@ -12,7 +12,9 @@ {% trans 'Authenticated by admins' %} - {% if user.api_token.is_active %} + {% if user.api_token is None %} + + {% elif user.api_token.is_active %} {% fa5_icon 'check-circle' %} {% else %} {% fa5_icon 'hourglass-half' %}