From dd8bd2a89b39240d583c5edc45b6c2d8cd5c7286 Mon Sep 17 00:00:00 2001 From: Sabrina Demagny Date: Sat, 14 Sep 2024 00:59:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20domain=20accesses=20list?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an endpoint to list all accesses created for a domain Return all roles available to set for each access depending to the authenticated user. --- CHANGELOG.md | 3 + .../mailbox_manager/api/serializers.py | 52 +++- src/backend/mailbox_manager/api/viewsets.py | 58 +++- .../test_api_mail_domain_accesses_list.py | 260 ++++++++++++++++++ .../test_api_mail_domain_accesses_retrieve.py | 118 ++++++++ 5 files changed, 482 insertions(+), 9 deletions(-) create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_list.py create mode 100644 src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_retrieve.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 221c84d2c..e203b02cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to ## [Unreleased] +✨(domains) add endpoint API to list all accesses created for a domain + + ## [1.1.0] - 2024-09-10 ### Added diff --git a/src/backend/mailbox_manager/api/serializers.py b/src/backend/mailbox_manager/api/serializers.py index 85795a59d..5b8686e3f 100644 --- a/src/backend/mailbox_manager/api/serializers.py +++ b/src/backend/mailbox_manager/api/serializers.py @@ -2,7 +2,9 @@ from rest_framework import serializers -from mailbox_manager import models +from core.api.serializers import UserSerializer + +from mailbox_manager import enums, models class MailboxSerializer(serializers.ModelSerializer): @@ -50,7 +52,10 @@ def get_abilities(self, domain) -> dict: class MailDomainAccessSerializer(serializers.ModelSerializer): - """Serialize mail domain accesses.""" + """Serialize mail domain access.""" + + user = UserSerializer(read_only=True, fields=["id", "name", "email"]) + can_set_role_to = serializers.SerializerMethodField(read_only=True) class Meta: model = models.MailDomainAccess @@ -58,7 +63,44 @@ class Meta: "id", "user", "role", - "created_at", - "updated_at", + "can_set_role_to", + ] + read_only_fields = ["id", "user", "can_set_role_to"] + + def get_can_set_role_to(self, access): + """Return roles available to set""" + roles = list(enums.MailDomainRoleChoices) + # get role of authenticated user + authenticated_user_role = access.user_role + if authenticated_user_role != enums.MailDomainRoleChoices.OWNER: + roles.remove(enums.MailDomainRoleChoices.OWNER) + # if the user authenticated is a viewer, they can't modify role + # and only an owner can change role of an owner + if authenticated_user_role == enums.MailDomainRoleChoices.VIEWER or ( + authenticated_user_role != enums.MailDomainRoleChoices.OWNER + and access.role == enums.MailDomainRoleChoices.OWNER + ): + return [] + # we only want to return other roles available to change, + # so we remove the current role of current access. + roles.remove(access.role) + return sorted(roles) + + +class MailDomainAccessReadOnlySerializer(MailDomainAccessSerializer): + """Serialize mail domain access for list and retrieve actions.""" + + class Meta: + model = models.MailDomainAccess + fields = [ + "id", + "user", + "role", + "can_set_role_to", + ] + read_only_fields = [ + "id", + "user", + "role", + "can_set_role_to", ] - read_only_fields = ["id"] diff --git a/src/backend/mailbox_manager/api/viewsets.py b/src/backend/mailbox_manager/api/viewsets.py index 15be4dfeb..c7a9ad366 100644 --- a/src/backend/mailbox_manager/api/viewsets.py +++ b/src/backend/mailbox_manager/api/viewsets.py @@ -1,5 +1,7 @@ """API endpoints""" +from django.db.models import Subquery + from rest_framework import filters, mixins, viewsets from rest_framework import permissions as drf_permissions @@ -54,19 +56,67 @@ def perform_create(self, serializer): # pylint: disable=too-many-ancestors class MailDomainAccessViewSet( - mixins.ListModelMixin, viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, ): """ - MailDomainAccess viewset. + API ViewSet for all interactions with mail domain accesses. + + GET /api/v1.0/mail-domains//accesses/: + Return list of all domain accesses related to the logged-in user and one + domain access if an id is provided. """ permission_classes = [drf_permissions.IsAuthenticated] serializer_class = serializers.MailDomainAccessSerializer filter_backends = [filters.OrderingFilter] - ordering_fields = ["created_at", "user", "domain", "role"] + ordering_fields = ["role", "user__email", "user__name"] ordering = ["-created_at"] - queryset = models.MailDomainAccess.objects.all() + queryset = ( + models.MailDomainAccess.objects.all() + .select_related("user") + .order_by("-created_at") + ) + list_serializer_class = serializers.MailDomainAccessReadOnlySerializer + detail_serializer_class = serializers.MailDomainAccessSerializer + + def get_serializer_class(self): + if self.action in {"list", "retrieve"}: + return self.list_serializer_class + return self.detail_serializer_class + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["domain_slug"] = self.kwargs["domain_slug"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"]) + + if self.action in {"list", "retrieve"}: + # Determine which role the logged-in user has in the domain + user_role_query = models.MailDomainAccess.objects.filter( + user=self.request.user, domain__slug=self.kwargs["domain_slug"] + ).values("role")[:1] + + queryset = ( + # The logged-in user should be part of a domain to see its accesses + queryset.filter( + domain__accesses__user=self.request.user, + ) + # Abilities are computed based on logged-in user's role and + # the user role on each domain access + .annotate( + user_role=Subquery(user_role_query), + ) + .select_related("user") + .distinct() + ) + return queryset class MailBoxViewSet( diff --git a/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_list.py b/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_list.py new file mode 100644 index 000000000..6e398f58d --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_list.py @@ -0,0 +1,260 @@ +""" +Test for mail_domain accesses API endpoints in People's core app : list +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domain__accesses_list_anonymous(): + """Anonymous users should not be allowed to list mail_domain accesses.""" + mail_domain = factories.MailDomainFactory() + factories.MailDomainAccessFactory.create_batch(2, domain=mail_domain) + + response = APIClient().get(f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_mail_domain__accesses_list_authenticated_unrelated(): + """ + Authenticated users should not be allowed to list mail_domain accesses for a mail_domain + to which they are not related. + """ + user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory() + factories.MailDomainAccessFactory.create_batch(3, domain=mail_domain) + + # Accesses for other mail_domains to which the user is related should not be listed either + other_access = factories.MailDomainAccessFactory(user=user) + factories.MailDomainAccessFactory(domain=other_access.domain) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + + +def test_api_mail_domain__accesses_list_for_authenticated_user_related_to_domain(): + """ + Authenticated users should be able to list mail_domain accesses for a mail_domain + to which they are related, with a given role. + """ + viewer, administrator, owner = core_factories.UserFactory.create_batch(3) + mail_domain = factories.MailDomainFactory() + + owner_access = factories.MailDomainAccessFactory.create( + domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER + ) + admin_access = factories.MailDomainAccessFactory.create( + domain=mail_domain, user=administrator, role=enums.MailDomainRoleChoices.ADMIN + ) + viewer_access = models.MailDomainAccess.objects.create( + domain=mail_domain, user=viewer, role=enums.MailDomainRoleChoices.VIEWER + ) + + admin_expected_data = { + "id": str(admin_access.id), + "user": { + "id": str(administrator.id), + "email": str(administrator.email), + "name": str(administrator.name), + }, + "role": str(admin_access.role), + } + viewer_expected_data = { + "id": str(viewer_access.id), + "user": { + "id": str(viewer.id), + "email": str(viewer.email), + "name": str(viewer.name), + }, + "role": str(viewer_access.role), + } + owner_expected_data = { + "id": str(owner_access.id), + "user": { + "id": str(owner.id), + "email": str(owner.email), + "name": str(owner.name), + }, + "role": str(owner_access.role), + } + + # Grant other mail_domain accesses to the user, they should not be listed either + other_access = factories.MailDomainAccessFactory(user=viewer) + factories.MailDomainAccessFactory(domain=other_access.domain) + + client = APIClient() + client.force_login(viewer) + # viewer can see accesses but no action is available + admin_expected_data["can_set_role_to"] = [] + viewer_expected_data["can_set_role_to"] = [] + owner_expected_data["can_set_role_to"] = [] + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + expected = sorted( + [admin_expected_data, viewer_expected_data, owner_expected_data], + key=lambda x: x["role"], + ) + assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected + + client.force_login(administrator) + # administrator can see and give new role but not an OWNER role + admin_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.VIEWER] + viewer_expected_data["can_set_role_to"] = [enums.MailDomainRoleChoices.ADMIN] + owner_expected_data["can_set_role_to"] = [] + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + expected = sorted( + [admin_expected_data, viewer_expected_data, owner_expected_data], + key=lambda x: x["role"], + ) + assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected + + client.force_login(owner) + # owner can do everything + admin_expected_data["can_set_role_to"] = [ + enums.MailDomainRoleChoices.OWNER, + enums.MailDomainRoleChoices.VIEWER, + ] + viewer_expected_data["can_set_role_to"] = [ + enums.MailDomainRoleChoices.ADMIN, + enums.MailDomainRoleChoices.OWNER, + ] + owner_expected_data["can_set_role_to"] = [ + enums.MailDomainRoleChoices.ADMIN, + enums.MailDomainRoleChoices.VIEWER, + ] + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + expected = sorted( + [admin_expected_data, viewer_expected_data, owner_expected_data], + key=lambda x: x["role"], + ) + assert sorted(response.json()["results"], key=lambda x: x["role"]) == expected + + +def test_api_mail_domain__accesses_list_authenticated_constant_numqueries( + django_assert_num_queries, +): + """ + The number of queries should not depend on the amount of fetched accesses. + """ + user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory() + models.MailDomainAccess.objects.create(domain=mail_domain, user=user) # random role + + client = APIClient() + client.force_login(user) + # Only 3 queries are needed to efficiently fetch mail_domain accesses, + # related users : + # - query retrieving logged-in user for user_role annotation + # - count from pagination + # - distinct from viewset + with django_assert_num_queries(3): + client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + + # create 20 new mail_domain accesses + for _ in range(20): + factories.MailDomainAccessFactory(domain=mail_domain) + + # num queries should still be the same + with django_assert_num_queries(3): + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/", + ) + + assert response.status_code == 200 + assert response.json()["count"] == 21 + + +def test_api_mail_domain__accesses_list_authenticated_ordering(): + """MailDomain accesses can be ordered by "role".""" + + user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory() + models.MailDomainAccess.objects.create(domain=mail_domain, user=user) + + # create 20 new mail_domain accesses + for _ in range(20): + factories.MailDomainAccessFactory(domain=mail_domain) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=role", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 21 + + results = [access["role"] for access in response.json()["results"]] + assert sorted(results) == results + + # check results when we change ordering + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=-role", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 21 + + results = [access["role"] for access in response.json()["results"]] + assert sorted(results, reverse=True) == results + + +@pytest.mark.parametrize("ordering_field", ["email", "name"]) +def test_api_mail_domain__accesses_list_authenticated_ordering_user(ordering_field): + """Mail domain accesses can be ordered by user's fields.""" + + user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory() + models.MailDomainAccess.objects.create(domain=mail_domain, user=user) + + for _ in range(20): + factories.MailDomainAccessFactory(domain=mail_domain) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/?ordering=user__{ordering_field}", + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["count"] == 21 + + def normalize(x): + """Mimic Django order_by, which is case-insensitive and space-insensitive""" + return x.casefold().replace(" ", "") + + results = [access["user"][ordering_field] for access in response.json()["results"]] + assert sorted(results, key=normalize) == results diff --git a/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_retrieve.py b/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_retrieve.py new file mode 100644 index 000000000..156dfd0c6 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mail_domain_accesses/test_api_mail_domain_accesses_retrieve.py @@ -0,0 +1,118 @@ +""" +Test for mail_domain accesses API endpoints in People's core app : retrieve +""" + +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import enums, factories + +pytestmark = pytest.mark.django_db + + +def test_api_mail_domain__accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a mail_domain access. + """ + access = factories.MailDomainAccessFactory() + + response = APIClient().get( + f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_mail_domain__accesses_retrieve_authenticated_unrelated(): + """ + Authenticated users should not be allowed to retrieve a mail_domain access for + a mail_domain to which they are not related. + """ + user = core_factories.UserFactory() + access = factories.MailDomainAccessFactory(domain=factories.MailDomainFactory()) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "No MailDomainAccess matches the given query."} + + # Accesses related to another mail_domain should be excluded even if the user is related to it + for other_access in [ + factories.MailDomainAccessFactory(), + factories.MailDomainAccessFactory(user=user), + ]: + response = client.get( + f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{other_access.id!s}/", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == { + "detail": "No MailDomainAccess matches the given query." + } + + +def test_api_mail_domain__accesses_retrieve_authenticated_related(): + """ + A user who is related to a mail_domain should be allowed to retrieve the + associated mail_domain user accesses. + """ + owner = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory() + access = factories.MailDomainAccessFactory( + domain=mail_domain, user=owner, role=enums.MailDomainRoleChoices.OWNER + ) + + client = APIClient() + client.force_login(owner) + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/", + ) + + results = { + "id": str(access.id), + "user": { + "id": str(access.user.id), + "email": str(owner.email), + "name": str(owner.name), + }, + "role": str(access.role), + "can_set_role_to": [ + enums.MailDomainRoleChoices.ADMIN, + enums.MailDomainRoleChoices.VIEWER, + ], + } + assert response.status_code == status.HTTP_200_OK + assert response.json() == results + + admin = factories.MailDomainAccessFactory( + domain=mail_domain, role=enums.MailDomainRoleChoices.ADMIN + ).user + client.force_login(admin) + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/", + ) + # admin can't change role of an owner + results["can_set_role_to"] = [] + assert response.status_code == status.HTTP_200_OK + assert response.json() == results + + viewer = factories.MailDomainAccessFactory( + domain=mail_domain, role=enums.MailDomainRoleChoices.VIEWER + ).user + client.force_login(viewer) + response = client.get( + f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/{access.id!s}/", + ) + # viewer can't change anyone's role + results["can_set_role_to"] = [] + assert response.status_code == status.HTTP_200_OK + assert response.json() == results