From ee6c094b07fe421e527ccf94e8e64b1e188f826f Mon Sep 17 00:00:00 2001 From: Daniele Rosetti <55402684+drosetti@users.noreply.github.com> Date: Tue, 31 Oct 2023 16:39:42 +0100 Subject: [PATCH 1/2] Admin invitation (#57) * added admin permission * test * debug * fix * fix * fix * removed useless import * updated tests * black --- certego_saas/apps/organization/membership.py | 2 +- certego_saas/apps/organization/permissions.py | 25 +- certego_saas/apps/organization/views.py | 40 +- tests/apps/organization/test_invitation.py | 13 +- tests/apps/organization/test_organization.py | 569 ++++++++++-------- 5 files changed, 380 insertions(+), 269 deletions(-) diff --git a/certego_saas/apps/organization/membership.py b/certego_saas/apps/organization/membership.py index 20eeff6..1fa731d 100644 --- a/certego_saas/apps/organization/membership.py +++ b/certego_saas/apps/organization/membership.py @@ -58,6 +58,6 @@ class ExistingMembershipException(ValidationError): class OwnerCantLeaveException(ValidationError): default_detail = ( - "Owner cannot leave the organization" + "Owner cannot leave the organization " "but can choose to delete the organization." ) diff --git a/certego_saas/apps/organization/permissions.py b/certego_saas/apps/organization/permissions.py index eaee3fb..a18d221 100644 --- a/certego_saas/apps/organization/permissions.py +++ b/certego_saas/apps/organization/permissions.py @@ -1,5 +1,7 @@ from rest_framework.permissions import BasePermission +from .invitation import Invitation +from .membership import Membership from .organization import Organization @@ -11,12 +13,17 @@ class InvitationDestroyObjectPermission(BasePermission): message = "Invitation was previously accepted or declined so cannot be deleted." - def has_object_permission(self, request, view, obj): + def has_object_permission(self, request, view, obj: Invitation): is_pending = obj.is_pending() - if not is_pending: + try: + Membership.objects.get( + user__username=request.user, + organization=obj.organization, + is_admin=True, + ) + except Membership.DoesNotExist: return False - is_authuser_org_owner = obj.organization.owner.pk == request.user.pk - return is_pending and is_authuser_org_owner + return is_pending class IsObjectOwnerPermission(BasePermission): @@ -28,6 +35,16 @@ def has_object_permission(self, request, view, obj): return False +class IsObjectAdminPermission(BasePermission): + def has_object_permission(self, request, view, obj: Organization): + try: + return Membership.objects.get( + user__username=request.user, organization=obj, is_admin=True + ) + except Membership.DoesNotExist: + return False + + class IsObjectSameOrgPermission(BasePermission): def has_object_permission(self, request, view, obj): if isinstance(obj, Organization): diff --git a/certego_saas/apps/organization/views.py b/certego_saas/apps/organization/views.py index 1b2cacd..44b80f7 100644 --- a/certego_saas/apps/organization/views.py +++ b/certego_saas/apps/organization/views.py @@ -4,7 +4,7 @@ from rest_flex_fields import is_expanded from rest_framework import status from rest_framework.decorators import action -from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -15,6 +15,7 @@ from .organization import Organization from .permissions import ( InvitationDestroyObjectPermission, + IsObjectAdminPermission, IsObjectOwnerPermission, IsObjectSameOrgPermission, ) @@ -61,7 +62,7 @@ def get_permissions(self): if self.request.method.lower() in ["delete"]: permissions.append(IsObjectOwnerPermission()) elif self.action in ["invite", "remove_member"]: - permissions.append(IsObjectOwnerPermission()) + permissions.append(IsObjectAdminPermission()) elif self.action in ["list", "retrieve", "leave"]: permissions.append(IsObjectSameOrgPermission()) return permissions @@ -99,7 +100,7 @@ def delete(self, request): @action(detail=False, methods=["POST"]) def invite(self, request, *args, **kwargs): """ - Invite user to organization (accessible only to the organization owner). + Invite user to organization (accessible only to the organization admin). ``POST ~/organization/invite``. """ @@ -120,22 +121,33 @@ def invite(self, request, *args, **kwargs): @action(detail=False, methods=["POST"]) def remove_member(self, request, *args, **kwargs): """ - Remove user's membership from organization (accessible only to the organization owner). + Remove user's membership from organization (accessible only to the organization admin). ``POST ~/organization/remove_member``. """ - username = request.data.get("username", None) - logger.info(f"remove member {username} from user {request.user}") - if not username: + username_to_remove = request.data.get("username", None) + logger.info(f"remove member {username_to_remove} from user {request.user}") + if not username_to_remove: raise ValidationError("'username' is required.") org = self.get_object() try: - membership = org.members.get(user__username=username) - if membership.is_owner: - raise ValidationError("Cannot remove organization owner.") + membership_request_user = org.members.get(user__username=request.user) + membership_user_to_remove = org.members.get( + user__username=username_to_remove + ) + if membership_user_to_remove.is_owner: + raise PermissionDenied( + detail="Cannot remove organization owner.", code=403 + ) + # only the owner can remove the admin + if ( + not membership_request_user.is_owner + and membership_user_to_remove.is_admin + ): + raise PermissionDenied(detail="Cannot remove another admin.", code=403) except Membership.DoesNotExist: raise ValidationError("No such member.") - membership.delete() + membership_user_to_remove.delete() return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=False, methods=["POST"]) @@ -146,7 +158,11 @@ def leave(self, request, *args, **kwargs): ``POST ~/organization/leave``. """ logger.info(f"leave membership org from user {request.user}") - if request.user.membership.is_owner: + try: + membership = request.user.membership + except Membership.DoesNotExist: + raise NotFound() + if membership.is_owner: raise Membership.OwnerCantLeaveException() request.user.membership.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/tests/apps/organization/test_invitation.py b/tests/apps/organization/test_invitation.py index 9f6ea81..07c95a3 100644 --- a/tests/apps/organization/test_invitation.py +++ b/tests/apps/organization/test_invitation.py @@ -55,7 +55,7 @@ def test_list_200(self): msg=msg, ) - def test_delete_204(self): + def test_delete_204_owner(self): """Invitation was created by user1 who is owner of organization so only user1 can delete it too @@ -67,6 +67,17 @@ def test_delete_204(self): # assert for API response self.assertEqual(204, response.status_code, msg=response) + def test_delete_204_admin(self): + user: User = User.objects.get_or_create(username="test_admin_org_1")[0] + Membership.objects.create(user=user, organization=self.org, is_admin=True) + + self.client.force_authenticate(user) + # delete invitation + response = self.__delete_invitation_api(self.invitation.id) + + # assert for API response + self.assertEqual(204, response.status_code, msg=response) + def test_delete_403(self): """invitation was created by user1 who is owner of organization, diff --git a/tests/apps/organization/test_organization.py b/tests/apps/organization/test_organization.py index 9c34600..bf61de0 100644 --- a/tests/apps/organization/test_organization.py +++ b/tests/apps/organization/test_organization.py @@ -1,12 +1,11 @@ -import logging - from django.db import transaction from django.test import tag from rest_framework.reverse import reverse from certego_saas.apps.organization.models import Invitation, Membership, Organization +from certego_saas.settings import certego_apps_settings -from ... import CustomTestCase, User, setup_custom_user +from ... import CustomTestCase, User org_uri = reverse("user_organization-list") org_leave_uri = reverse("user_organization-leave") @@ -17,276 +16,344 @@ @tag("apps", "organization") class TestOrganization(CustomTestCase): @classmethod - def setUpClass(cls): - # create 2 test users - cls.user1: User = User.objects.get_or_create(username="testorguser1")[0] - cls.user2: User = User.objects.get_or_create(username="testorguser2")[0] - # setup client - cls.client = setup_custom_user(cls.user1) - return super(TestOrganization, cls).setUpClass() + def setUpClass(cls) -> None: + super().setUpClass() + # change org user limit + Organization.MAX_MEMBERS = 5 + # create test users + cls.user_owner_org_1: User = User.objects.get_or_create( + username="test_user_owner_org_1" + )[0] + cls.user_admin_org_1: User = User.objects.get_or_create( + username="test_user_admin_org_1" + )[0] + cls.user_admin2_org_1: User = User.objects.get_or_create( + username="test_user_admin2_org_1" + )[0] + cls.user_common_org_1: User = User.objects.get_or_create( + username="test_user_common_org_1" + )[0] + cls.user_common2_org_1: User = User.objects.get_or_create( + username="test_user_common2_org_1" + )[0] + cls.user_owner_org_2: User = User.objects.get_or_create( + username="test_user_owner_org_2" + )[0] + cls.user_common_org_2: User = User.objects.get_or_create( + username="test_user_common_org_2" + )[0] + cls.user_no_org: User = User.objects.get_or_create(username="test_user_no_org")[ + 0 + ] + cls.user_invited: User = User.objects.get_or_create( + username="test_user_invited" + )[0] + # NOTE: orgs and memberships are created in the setUp because some tests change them - @transaction.atomic - def setUp(self): + @classmethod + def tearDownClass(cls) -> None: + super().tearDownClass() + # reset org member limit + Organization.MAX_MEMBERS = certego_apps_settings.ORGANIZATION_MAX_MEMBERS + # clean db + Invitation.objects.all().delete() Membership.objects.all().delete() Organization.objects.all().delete() - self.user1.refresh_from_db() - self.user2.refresh_from_db() - self.client.force_authenticate(user=self.user1) - return super().setUp() - # TEST CASES + def setUp(self) -> None: + super().setUp() + # clean db + Invitation.objects.all().delete() + Membership.objects.all().delete() + Organization.objects.all().delete() + # create test orgs + self.org_1: Organization = Organization.create( + name="testorg1", owner=self.user_owner_org_1 + ) + self.org_2: Organization = Organization.create( + name="testorg2", owner=self.user_owner_org_2 + ) + # add users to the orgs + Membership.objects.create( + user=self.user_admin_org_1, organization=self.org_1, is_admin=True + ) + Membership.objects.create( + user=self.user_admin2_org_1, organization=self.org_1, is_admin=True + ) + Membership.objects.create(user=self.user_common_org_1, organization=self.org_1) + Membership.objects.create(user=self.user_common2_org_1, organization=self.org_1) + Membership.objects.create(user=self.user_common_org_2, organization=self.org_2) + # add invite + Invitation.objects.create(user=self.user_invited, organization=self.org_2) + # refresh the user + self.user_owner_org_1.refresh_from_db() + self.user_admin_org_1.refresh_from_db() + self.user_admin2_org_1.refresh_from_db() + self.user_common_org_1.refresh_from_db() + self.user_common2_org_1.refresh_from_db() + self.user_owner_org_2.refresh_from_db() + self.user_common_org_2.refresh_from_db() + self.user_no_org.refresh_from_db() + self.user_invited.refresh_from_db() + + def tearDown(self) -> None: + super().tearDown() + Invitation.objects.all().delete() + Membership.objects.all().delete() + Organization.objects.all().delete() - def test_get_org_expand_200(self): - self.assertEqual(0, Organization.objects.count(), msg="No organization exists") + def test_correct_org_creation(self): + """User without an org can create an org""" + self.assertEqual(2, Organization.objects.count()) # create org - org = self.__create_org_add_member() - - # API get call - response = self.client.get(f"{org_uri}?expand=members,pending_invitations") + self.client.force_authenticate(self.user_no_org) + response = self.client.post(org_uri, {"name": "new_org"}) + self.assertEqual(201, response.status_code) content = response.json() - msg = (response, content) - # asserts - self.assertEqual(1, Organization.objects.count(), msg="1 organization exists") - - self.assertEqual(200, response.status_code, msg=msg) - self.assertEqual(org.name, content["name"], msg=msg) - self.assertEqual(2, content["members_count"], msg=msg) - self.assertTrue(content["is_user_owner"], msg=msg) - self.assertEqual(self.user1.username, content["owner"]["username"], msg=msg) - self.assertEqual(2, len(content["members"]), msg=msg) - self.assertIn("pending_invitations", content, msg=msg) - - def test_create_201_400(self): - # creating new org should return 201 - response = self.client.post(org_uri, data={"name": "testOrg1"}) + self.assertEqual(3, Organization.objects.count()) + self.assertEqual("new_org", content["name"]) + self.assertEqual(1, content["members_count"]) + self.assertTrue(content["is_user_owner"]) + self.assertEqual(self.user_no_org.username, content["owner"]["username"]) + self.assertTrue(content["owner"]["is_admin"]) + + def test_error_org_creation(self): + """User in an org (owner, admin or common user) cannot create a new org while they are members.""" + # error in case the member of an org (owner, admin or common user) try to create a new org + # owner user + self.client.force_authenticate(self.user_owner_org_1) + response = self.client.post(org_uri, data={"name": "no_org"}) content = response.json() - msg = (response, content) - - self.assertEqual(201, response.status_code, msg=msg) - self.assertEqual("testOrg1", content["name"], msg=msg) - self.assertEqual(self.user1.username, content["owner"]["username"], msg=msg) - self.assertEqual(1, content["members_count"], msg=msg) - self.assertTrue(content["is_user_owner"], msg=msg) - - # creating new org when org exists should return 400 - response = self.client.post(org_uri, data={"name": "testOrg2"}) + self.assertIn( + Membership.ExistingMembershipException.default_detail, content["errors"] + ) + # admin user + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post(org_uri, data={"name": "no_org"}) content = response.json() - msg = (response, content) - - self.assertEqual(400, response.status_code, msg=msg) self.assertIn( - Membership.ExistingMembershipException.default_detail, - content["errors"], - msg=msg, + Membership.ExistingMembershipException.default_detail, content["errors"] + ) + # common user + self.client.force_authenticate(self.user_common_org_1) + response = self.client.post(org_uri, data={"name": "no_org"}) + content = response.json() + self.assertIn( + Membership.ExistingMembershipException.default_detail, content["errors"] ) - def test_delete_204(self): - """ - Org's owner is user1, so can delete the org - """ - self.__create_org_add_member() - - # /remove_member API call + def test_correct_org_deletion(self): + """Org's owner (user_owner_org_1) can delete the org""" + self.client.force_authenticate(self.user_owner_org_1) response = self.client.delete(org_uri) + self.assertEqual(204, response.status_code) - # assert API response - self.assertEqual(204, response.status_code, msg=response) - - def test_delete_403(self): - """ - Org's owner is user1, user2 is just a member so can't delete the org - """ - self.__create_org_add_member() + def test_error_org_deletion(self): + """Members of an org without owner role or user without an org cannot delete the org""" + # admin user + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.delete(org_uri) + self.assertEqual(403, response.status_code) + self.assertEqual( + "You do not have permission to perform this action.", + response.json()["detail"], + ) + # common user + self.client.force_authenticate(self.user_common_org_1) + response = self.client.delete(org_uri) + self.assertEqual(403, response.status_code) + self.assertEqual( + "You do not have permission to perform this action.", + response.json()["detail"], + ) + # user no org + self.client.force_authenticate(self.user_no_org) + response = self.client.delete(org_uri) + self.assertEqual(404, response.status_code) + self.assertCountEqual(response.json()["errors"], {"organization": "Not found."}) - # /remove_member API call - self.client.force_authenticate(user=self.user2) - with self.assertLogs(level=logging.ERROR): - response = self.client.delete(org_uri) + def test_correct_leave_org(self): + """Members of org without owner role can leave the org""" + # admin user + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post(org_leave_uri) + self.assertEqual(204, response.status_code) + # common user + self.client.force_authenticate(self.user_common_org_1) + response = self.client.post(org_leave_uri) + self.assertEqual(204, response.status_code) - # assert API response - self.assertEqual(403, response.status_code, msg=response) + def test_error_leave_org(self): + """Owner of an org or user without membership of an org cannot leave an org""" + # owner user + self.client.force_authenticate(self.user_owner_org_1) + response = self.client.post(org_leave_uri) + self.assertEqual(400, response.status_code) + self.assertIn( + "Owner cannot leave the organization but can choose to delete the organization.", + response.json()["errors"], + ) + # no org user + self.client.force_authenticate(self.user_no_org) + response = self.client.post(org_leave_uri) + self.assertEqual(404, response.status_code) + self.assertCountEqual(response.json()["errors"], {"organization": "Not found."}) + + def test_correct_remove_user_from_org(self): + """Only users with admin or owner roles can remove users from their org""" + # owner remove a user + self.client.force_authenticate(self.user_owner_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_admin2_org_1.username} + ) + self.assertEqual(204, response.status_code) + # admin remove a user + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_common_org_1.username} + ) + self.assertEqual(204, response.status_code) - def test_leave_204(self): + def test_error_remove_user_from_org(self): """ - Org's owner is user1, user2 is just a member so can leave + 1 - common user cannot remove users (owner, admin or other user) + 2 - admin cannot remove neither the owner nor other admin + 3 - request without a username + 4 - request with a username not existing + 5 - request with a valid username, but it's not a member + 6 - request with a valid username and member of another org + 7 - user with no org """ - self.__create_org_add_member() - - # /remove_member API call - self.client.force_authenticate(user=self.user2) - response = self.client.post(org_leave_uri) - - # assert API response - self.assertEqual(204, response.status_code, msg=response) - - def test_leave_400_owner_cant_leave(self): + # 1 - common user cannot remove users (owner, admin or other user) + self.client.force_authenticate(self.user_common_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_owner_org_1.username} + ) + self.assertEqual(403, response.status_code) + response = self.client.post( + org_remove_member_uri, {"username": self.user_admin_org_1.username} + ) + self.assertEqual(403, response.status_code) + response = self.client.post( + org_remove_member_uri, {"username": self.user_common2_org_1.username} + ) + self.assertEqual(403, response.status_code) + # 2 - admin cannot remove neither the owner nor other admin + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_owner_org_1.username} + ) + self.assertEqual(403, response.status_code) + self.assertEqual("Cannot remove organization owner.", response.json()["detail"]) + response = self.client.post( + org_remove_member_uri, {"username": self.user_admin2_org_1.username} + ) + self.assertEqual(403, response.status_code) + self.assertEqual("Cannot remove another admin.", response.json()["detail"]) + # 3 - request without a username + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post(org_remove_member_uri, {"username": ""}) + self.assertEqual(400, response.status_code) + self.assertIn("'username' is required.", response.json()["errors"]) + # 4 - request with a username not existing + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post( + org_remove_member_uri, {"username": "not_existing_user"} + ) + self.assertEqual(400, response.status_code) + self.assertIn("No such member.", response.json()["errors"]) + # 5 - request with a valid username, but it's not a member + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_no_org.username} + ) + self.assertEqual(400, response.status_code) + self.assertIn("No such member.", response.json()["errors"]) + # 6 - request with a valid username and member of another org + self.client.force_authenticate(self.user_admin_org_1) + response = self.client.post( + org_remove_member_uri, {"username": self.user_common_org_2.username} + ) + self.assertEqual(400, response.status_code) + self.assertIn("No such member.", response.json()["errors"]) + # 7 - user with no org + self.client.force_authenticate(self.user_no_org) + response = self.client.post( + org_remove_member_uri, {"username": self.user_common_org_2.username} + ) + self.assertEqual(404, response.status_code) + + def test_correct_invite(self): + """Owner and admins can invite users""" + # owner can invite + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post(org_invite_uri, {"username": self.user_no_org}) + self.assertEqual(201, response.status_code) + Invitation.objects.get(user=self.user_no_org, organization=self.org_2).delete() + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post(org_invite_uri, {"username": self.user_no_org}) + self.assertEqual(201, response.status_code) + + def test_error_invite(self): """ - Org's owner is user1, so can't leave + 1 - Cannot invite member + 2 - Common user cannot invite members + 3 - Cannot invite without username nor blank + 4 - Cannot invite not existing username + 5 - Cannot invite if an invite is already pending + 6 - Cannot invite if max member number is reached + 7 - cannot invite if no member of org """ - self.__create_org_add_member() - - # /remove_member API call - response = self.client.post(org_leave_uri) - content = response.json() - msg = (response, content) - exc = Membership.OwnerCantLeaveException - - # assert API response - self.assertEqual(exc.status_code, response.status_code, msg=msg) - self.assertIn(exc.default_detail, content["errors"], msg=msg) - - def test_invite_201(self): - # create org - org = Organization.create(name="testorg", owner=self.user1) - self.user1.refresh_from_db() - + # 1 - Cannot invite member + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post( + org_invite_uri, {"username": self.user_common_org_2} + ) + self.assertEqual(400, response.status_code) + self.assertIn("Invite failed.", response.json()["errors"]) + # 2 - Common user cannot invite members + self.client.force_authenticate(self.user_common_org_2) + response = self.client.post(org_invite_uri, {"username": self.user_no_org}) + self.assertEqual(403, response.status_code) + # 3 - Cannot invite without username + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post(org_invite_uri) + self.assertEqual(400, response.status_code) self.assertEqual( - 0, self.user2.invitations.count(), msg="currently user2 has no invitations" + {"username": ["This field is required."]}, response.json()["errors"] ) - - # invite user2 - response, content = self.__send_invite(self.user2.get_username()) - msg = (response, content) - - # asserts - self.assertEqual(201, response.status_code, msg=msg) - - # user2 should have one invitation - self.assertEqual(1, self.user2.invitations.count(), msg=msg) - self.assertEqual(org, self.user2.invitations.first().organization, msg=msg) - - def test_invite_400_invalid_username(self): - # create org - _ = Organization.create(name="testorg", owner=self.user1) - self.user1.refresh_from_db() - - # invite an invalid username - response, content = self.__send_invite("blahblahblah") - msg = (response, content) - - # asserts - self.assertEqual(400, response.status_code, msg=msg) - self.assertIn("Failed", content["errors"]["detail"], msg=msg) - - def test_invite_400_cant_invite_owner(self): - # create org - _ = Organization.create(name="testorg", owner=self.user1) - self.user1.refresh_from_db() - - # invite self user - response, content = self.__send_invite(self.user1.get_username()) - msg = (response, content) - exc = Invitation.OwnerException - - # asserts - self.assertEqual(exc.status_code, response.status_code, msg=msg) - self.assertIn(exc.default_detail, content["errors"], msg=msg) - - def test_invite_400_existing_member(self): - # create org and add member - self.__create_org_add_member() - - # invite user2 - response, content = self.__send_invite(self.user2.get_username()) - msg = (response, content) - exc = Invitation.InviteFailedException - - # asserts - self.assertEqual(exc.status_code, response.status_code, msg=msg) - self.assertIn(exc.default_detail, content["errors"], msg=msg) - - def test_invite_400_already_pending(self): - # create org - _ = Organization.create(name="testorg", owner=self.user1) - self.user1.refresh_from_db() - - # invite user 2 - response, content = self.__send_invite(self.user2.get_username()) - self.assertEqual(201, response.status_code, msg=(response, content)) - # invite user2 again - response, content = self.__send_invite(self.user2.get_username()) - msg = (response, content) - exc = Invitation.InviteFailedException - - # asserts - self.assertEqual(exc.status_code, response.status_code, msg=msg) - self.assertIn(exc.default_detail, content["errors"], msg=msg) - - def test_invite_400_too_many_members(self): - # forcing 1 member max - Organization.MAX_MEMBERS = 1 - # create org - _ = Organization.create(name="testorg", owner=self.user1) - self.user1.refresh_from_db() - # invite user 2 - response, content = self.__send_invite(self.user2.get_username()) - exc = Invitation.MaxMemberException - msg = (response, content) - self.assertEqual(exc.status_code, response.status_code, msg=msg) - self.assertIn(exc.default_detail, content["errors"], msg=msg) - Organization.MAX_MEMBERS = 3 - - def test_remove_member_204(self): - self.__create_org_add_member() - - # /remove_member API call - response = self.__remove_member(self.user2.get_username()) - - # assert API response - self.assertEqual(204, response.status_code, msg=response) - - def test_remove_member_400_no_username(self): - self.__create_org_add_member() - - # /remove_member API call - response = self.client.post(org_remove_member_uri) - content = response.json() - msg = (response, content) - - # assert API response - self.assertEqual(400, response.status_code, msg=msg) - - def test_remove_member_400_cant_remove_owner(self): - self.__create_org_add_member() - - # /remove_member API call - response = self.__remove_member(self.user1.get_username()) - content = response.json() - msg = (response, content) - - # assert API response - self.assertEqual(400, response.status_code, msg=msg) - - def test_remove_member_400_no_such_member(self): - self.__create_org_add_member() - - # /remove_member API call - response = self.__remove_member("blahblahblah") - content = response.json() - msg = (response, content) - - # assert API response - self.assertEqual(400, response.status_code, msg=msg) - self.assertIn("No such member.", content["errors"], msg=msg) - - # UTILITY METHODS - - def __create_org_add_member(self): - # create org - org = Organization.create(name="testorg", owner=self.user1) - - # add user2 as member - self.assertFalse(self.user2.has_membership(), msg="user2 has no membership") - Membership.objects.create(user=self.user2, organization=org) - self.assertTrue(self.user2.has_membership(), msg="user2 now has membership") - - return org - - def __send_invite(self, username): - resp = self.client.post(org_invite_uri, {"username": username}) - return resp, resp.json() - - def __remove_member(self, username): - return self.client.post(org_remove_member_uri, {"username": username}) + response = self.client.post(org_invite_uri, {"username": ""}) + self.assertEqual(400, response.status_code) + self.assertEqual( + {"username": ["This field may not be blank."]}, response.json()["errors"] + ) + # 4 - Cannot invite not existing username + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post(org_invite_uri, {"username": "not_existing_user"}) + self.assertEqual(400, response.status_code) + self.assertEqual({"detail": "Failed"}, response.json()["errors"]) + # 5 - Cannot invite if an invitation is already pending + with transaction.atomic(): + self.client.force_authenticate(self.user_owner_org_2) + response = self.client.post( + org_invite_uri, {"username": self.user_invited.username} + ) + self.assertEqual(400, response.status_code) + self.assertIn("Invite failed.", response.json()["errors"]) + # 6 - Cannot invite if max member number is reached + self.client.force_authenticate(self.user_owner_org_1) + response = self.client.post( + org_invite_uri, {"username": self.user_no_org.username} + ) + self.assertEqual(400, response.status_code) + self.assertIn( + Invitation.MaxMemberException.default_detail, response.json()["errors"] + ) + # 7 - cannot invite if no member of org + self.client.force_authenticate(self.user_no_org) + response = self.client.post( + org_invite_uri, {"username": self.user_owner_org_1.username} + ) + self.assertEqual(404, response.status_code) From c5a3f028e74f1ed8ff7a29ef059c1d066c2f4637 Mon Sep 17 00:00:00 2001 From: Daniele Rosetti Date: Tue, 31 Oct 2023 16:51:45 +0100 Subject: [PATCH 2/2] release update --- .github/CHANGELOG.md | 5 +++++ certego_saas/version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md index 02c9c88..b37fd86 100644 --- a/.github/CHANGELOG.md +++ b/.github/CHANGELOG.md @@ -2,6 +2,11 @@ **[Get it on PyPi](https://pypi.org/project/certego-saas/)** +## [0.7.3](https://github.com/certego/certego-saas/releases/tag/0.1.1) + +**New features:** +- Admin can invite users to join the organization and revoke the invitations. + ## [0.1.1](https://github.com/certego/certego-saas/releases/tag/0.1.1) **New features:** diff --git a/certego_saas/version.py b/certego_saas/version.py index 2400ae8..a10d59f 100644 --- a/certego_saas/version.py +++ b/certego_saas/version.py @@ -1 +1 @@ -VERSION = "0.7.2" +VERSION = "0.7.3"