(backend) domain accesses list API

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.
This commit is contained in:
Sabrina Demagny
2024-09-14 00:59:38 +02:00
parent cc86a3bd61
commit dd8bd2a89b
5 changed files with 482 additions and 9 deletions

View File

@@ -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

View File

@@ -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 @@ class MailDomainSerializer(serializers.ModelSerializer):
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 MailDomainAccessSerializer(serializers.ModelSerializer):
"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"]

View File

@@ -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 @@ class MailDomainViewSet(
# 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/<domain_slug>/accesses/:<domain_access_id>
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(

View File

@@ -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

View File

@@ -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