diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 24fd9b96..3d300b65 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -62,3 +62,17 @@ class UserAdmin(auth_admin.UserAdmin): ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at") readonly_fields = ("id", "sub", "email", "created_at", "updated_at") search_fields = ("id", "sub", "admin_email", "email") + + +class ResourceAccessInline(admin.TabularInline): + """Admin class for the room user access model""" + + model = models.ResourceAccess + extra = 0 + + +@admin.register(models.Room) +class RoomAdmin(admin.ModelAdmin): + """Room admin interface declaration.""" + + inlines = (ResourceAccessInline,) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 3df55257..ca3e256c 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -1,6 +1,8 @@ """Permission handlers for the impress core app.""" from rest_framework import permissions +from ..models import RoleChoices + ACTION_FOR_METHOD_TO_PERMISSION = { "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} } @@ -34,3 +36,49 @@ class IsSelf(IsAuthenticated): def has_object_permission(self, request, view, obj): """Write permissions are only allowed to the user itself.""" return obj == request.user + + +class RoomPermissions(permissions.BasePermission): + """ + Permissions applying to the room API endpoint. + """ + + def has_permission(self, request, view): + """Only allow authenticated users for unsafe methods.""" + if request.method in permissions.SAFE_METHODS: + return True + + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + """Object permissions are only given to administrators of the room.""" + + if request.method in permissions.SAFE_METHODS: + return True + + user = request.user + + if request.method == "DELETE": + return obj.is_owner(user) + + return obj.is_administrator(user) + + +class ResourceAccessPermission(permissions.BasePermission): + """ + Permissions for a room that can only be updated by room administrators. + """ + + def has_permission(self, request, view): + """Only allow authenticated users.""" + return request.user.is_authenticated + + def has_object_permission(self, request, view, obj): + """ + Check that the logged-in user is administrator of the linked room. + """ + user = request.user + if request.method == "DELETE" and obj.role == RoleChoices.OWNER: + return obj.user == user + + return obj.resource.is_administrator(user) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 5a7186f9..9c745d6d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,5 +1,8 @@ """Client serializers for the impress core app.""" +from django.utils.translation import gettext_lazy as _ + from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from core import models @@ -11,3 +14,120 @@ class UserSerializer(serializers.ModelSerializer): model = models.User fields = ["id", "email"] read_only_fields = ["id", "email"] + + +class ResourceAccessSerializerMixin: + """ + A serializer mixin to share controlling that the logged-in user submitting a room access object + is administrator on the targeted room. + """ + + # pylint: disable=too-many-boolean-expressions + def validate(self, data): + """ + Check access rights specific to writing (create/update) + """ + request = self.context.get("request", None) + user = getattr(request, "user", None) + if ( + # Update + self.instance + and ( + data["role"] == models.RoleChoices.OWNER + and not self.instance.resource.is_owner(user) + or self.instance.role == models.RoleChoices.OWNER + and not self.instance.user == user + ) + ) or ( + # Create + not self.instance + and data.get("role") == models.RoleChoices.OWNER + and not data["resource"].is_owner(user) + ): + raise PermissionDenied( + "Only owners of a room can assign other users as owners." + ) + return data + + def validate_resource(self, resource): + """The logged-in user must be administrator of the resource.""" + request = self.context.get("request", None) + user = getattr(request, "user", None) + + if not (user and user.is_authenticated and resource.is_administrator(user)): + raise PermissionDenied( + _("You must be administrator or owner of a room to add accesses to it.") + ) + + return resource + + +class ResourceAccessSerializer( + ResourceAccessSerializerMixin, serializers.ModelSerializer +): + """Serialize Room to User accesses for the API.""" + + class Meta: + model = models.ResourceAccess + fields = ["id", "user", "resource", "role"] + read_only_fields = ["id"] + + def update(self, instance, validated_data): + """Make "user" and "resource" fields readonly but only on update.""" + validated_data.pop("resource", None) + validated_data.pop("user", None) + return super().update(instance, validated_data) + + +class NestedResourceAccessSerializer(ResourceAccessSerializer): + """Serialize Room accesses for the API with full nested user.""" + + user = UserSerializer(read_only=True) + + +class RoomSerializer(serializers.ModelSerializer): + """Serialize Room model for the API.""" + + class Meta: + model = models.Room + fields = ["id", "name", "slug", "configuration", "is_public"] + read_only_fields = ["id", "slug"] + + def to_representation(self, instance): + """ + Add users only for administrator users. + Add LiveKit credentials for public instance or related users/groups + """ + output = super().to_representation(instance) + request = self.context.get("request") + + if not request: + return output + + role = instance.get_role(request.user) + is_admin = models.RoleChoices.check_administrator_role(role) + + if role is not None: + access_serializer = NestedResourceAccessSerializer( + instance.accesses.select_related("resource", "user").all(), + context=self.context, + many=True, + ) + output["accesses"] = access_serializer.data + + if not is_admin: + del output["configuration"] + + if role is not None or instance.is_public: + output["livekit"] = { + # todo - generate a proper livekit name + "room": "foo", + # todo - generate a proper token + "token": "foo", + } + + output["is_administrable"] = is_admin + + # todo - pass properly livekit configuration + + return output diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 223aa871..50083cfd 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,4 +1,12 @@ """API endpoints""" +import uuid + +from django.conf import settings +from django.db.models import Q +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils.text import slugify + from rest_framework import ( decorators, mixins, @@ -140,3 +148,131 @@ class UserViewSet( return drf_response.Response( self.serializer_class(request.user, context=context).data ) + + +class RoomViewSet( + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + API endpoints to access and perform actions on rooms. + """ + + permission_classes = [permissions.RoomPermissions] + queryset = models.Room.objects.all() + serializer_class = serializers.RoomSerializer + + def get_object(self): + """Allow getting a room by its slug.""" + try: + uuid.UUID(self.kwargs["pk"]) + filter_kwargs = {"pk": self.kwargs["pk"]} + except ValueError: + filter_kwargs = {"slug": slugify(self.kwargs["pk"])} + queryset = self.filter_queryset(self.get_queryset()) + obj = get_object_or_404(queryset, **filter_kwargs) + # May raise a permission denied + self.check_object_permissions(self.request, obj) + return obj + + def retrieve(self, request, *args, **kwargs): + """ + Allow unregistered rooms when activated. + For unregistered rooms we only return a null id and the livekit room and token. + """ + try: + instance = self.get_object() + except Http404: + if not settings.ALLOW_UNREGISTERED_ROOMS: + raise + slug = slugify(self.kwargs["pk"]) + data = { + "id": None, + "livekit": { + "room": slug, + # todo - generate a proper token + "token": "foo", + }, + } + else: + data = self.get_serializer(instance).data + + return drf_response.Response(data) + + def list(self, request, *args, **kwargs): + """Limit listed rooms to the ones related to the authenticated user.""" + user = self.request.user + + if user.is_authenticated: + # todo - simplify this queryset + queryset = ( + self.filter_queryset(self.get_queryset()) + .filter(Q(users=user)) + .distinct() + ) + else: + queryset = self.get_queryset().none() + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return drf_response.Response(serializer.data) + + def perform_create(self, serializer): + """Set the current user as owner of the newly created room.""" + room = serializer.save() + models.ResourceAccess.objects.create( + resource=room, + user=self.request.user, + role=models.RoleChoices.OWNER, + ) + + +class ResourceAccessListModelMixin: + """List mixin for resource access API.""" + + def get_permissions(self): + """User only needs to be authenticated to list rooms access""" + if self.action == "list": + permission_classes = [permissions.IsAuthenticated] + else: + return super().get_permissions() + + return [permission() for permission in permission_classes] + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + if self.action == "list": + user = self.request.user + queryset = queryset.filter( + Q(resource__accesses__user=user), + resource__accesses__role__in=[ + models.RoleChoices.ADMIN, + models.RoleChoices.OWNER, + ], + ).distinct() + return queryset + + +class ResourceAccessViewSet( + ResourceAccessListModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + """ + API endpoints to access and perform actions on resource accesses. + """ + + permission_classes = [permissions.ResourceAccessPermission] + queryset = models.ResourceAccess.objects.all() + serializer_class = serializers.ResourceAccessSerializer diff --git a/src/backend/core/tests/rooms/__init__.py b/src/backend/core/tests/rooms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/rooms/test_api_rooms_create.py b/src/backend/core/tests/rooms/test_api_rooms_create.py new file mode 100644 index 00000000..78e0e34d --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_create.py @@ -0,0 +1,71 @@ +""" +Test rooms API endpoints in the impress core app: create. +""" +import pytest +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory +from ...models import Room + +pytestmark = pytest.mark.django_db + + +def test_api_rooms_create_anonymous(): + """Anonymous users should not be allowed to create rooms.""" + client = APIClient() + + response = client.post( + "/api/v1.0/rooms/", + { + "name": "my room", + }, + ) + + assert response.status_code == 401 + assert Room.objects.exists() is False + + +def test_api_rooms_create_authenticated(): + """ + Authenticated users should be able to create rooms and should automatically be declared + as owner of the newly created room. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/rooms/", + { + "name": "my room", + }, + ) + + assert response.status_code == 201 + room = Room.objects.get() + assert room.name == "my room" + assert room.slug == "my-room" + assert room.accesses.filter(role="owner", user=user).exists() is True + + +def test_api_rooms_create_authenticated_existing_slug(): + """ + A user trying to create a room with a name that translates to a slug that already exists + should receive a 400 error. + """ + RoomFactory(name="my room") + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/rooms/", + { + "name": "My Room!", + }, + ) + + assert response.status_code == 400 + assert response.json() == {"slug": ["Room with this Slug already exists."]} diff --git a/src/backend/core/tests/rooms/test_api_rooms_delete.py b/src/backend/core/tests/rooms/test_api_rooms_delete.py new file mode 100644 index 00000000..a37640a2 --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_delete.py @@ -0,0 +1,101 @@ +""" +Test rooms API endpoints in the impress core app: delete. +""" +import pytest +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory +from ...models import Room + +pytestmark = pytest.mark.django_db + + +def test_api_rooms_delete_anonymous(): + """Anonymous users should not be allowed to destroy a room.""" + room = RoomFactory() + client = APIClient() + + response = client.delete( + f"/api/v1.0/rooms/{room.id!s}/", + ) + + assert response.status_code == 401 + assert Room.objects.count() == 1 + + +def test_api_rooms_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a room to which they are not + related. + """ + room = RoomFactory() + user = UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/rooms/{room.id!s}/", + ) + + assert response.status_code == 403 + assert Room.objects.count() == 1 + + +def test_api_rooms_delete_members(): + """ + Authenticated users should not be allowed to delete a room for which they are + only a member. + """ + user = UserFactory() + room = RoomFactory( + users=[(user, "member")] + ) # as user declared in the room but not administrator + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/rooms/{room.id}/", + ) + + assert response.status_code == 403 + assert Room.objects.count() == 1 + + +def test_api_rooms_delete_administrators(): + """ + Authenticated users should not be allowed to delete a room for which they are + administrator. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "administrator")]) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/rooms/{room.id}/", + ) + + assert response.status_code == 403 + assert Room.objects.count() == 1 + + +def test_api_rooms_delete_owners(): + """ + Authenticated users should be able to delete a room for which they are directly + owner. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/rooms/{room.id}/", + ) + + assert response.status_code == 204 + assert Room.objects.exists() is False diff --git a/src/backend/core/tests/rooms/test_api_rooms_list.py b/src/backend/core/tests/rooms/test_api_rooms_list.py new file mode 100644 index 00000000..0b5aa8ff --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_list.py @@ -0,0 +1,116 @@ +""" +Test rooms API endpoints in the impress core app: list. +""" +from unittest import mock + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory + +pytestmark = pytest.mark.django_db + + +def test_api_rooms_list_anonymous(): + """Anonymous users should not be able to list rooms.""" + RoomFactory(is_public=False) + RoomFactory(is_public=True) + + client = APIClient() + + response = client.get("/api/v1.0/rooms/") + assert response.status_code == 200 + + results = response.json()["results"] + assert len(results) == 0 + + +def test_api_rooms_list_authenticated(): + """ + Authenticated users listing rooms, should only see the rooms + to which they are related. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + other_user = UserFactory() + + RoomFactory(is_public=False) + RoomFactory(is_public=True) + room_user_accesses = RoomFactory(is_public=False, users=[user]) + RoomFactory(is_public=False, users=[other_user]) + + response = client.get( + "/api/v1.0/rooms/", + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 1 + expected_ids = { + str(room_user_accesses.id), + } + results_id = {result["id"] for result in results} + assert expected_ids == results_id + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_rooms_list_pagination(_mock_page_size): + """Pagination should work as expected.""" + user = UserFactory() + client = APIClient() + client.force_login(user) + + rooms = RoomFactory.create_batch(3, users=[user]) + room_ids = [str(room.id) for room in rooms] + + response = client.get( + "/api/v1.0/rooms/", + ) + + assert response.status_code == 200 + content = response.json() + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + room_ids.remove(item["id"]) + + # Get page 2 + response = client.get( + "/api/v1.0/rooms/?page=2", + ) + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"], "http://testserver/api/v1.0/rooms/" + + assert len(content["results"]) == 1 + room_ids.remove(content["results"][0]["id"]) + assert room_ids == [] + + +def test_api_rooms_list_authenticated_distinct(): + """A public room with several related users should only be listed once.""" + user = UserFactory() + other_user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory(is_public=True, users=[user, other_user]) + + response = client.get( + "/api/v1.0/rooms/", + ) + + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 1 + assert content["results"][0]["id"] == str(room.id) diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py new file mode 100644 index 00000000..9ab4f44c --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -0,0 +1,344 @@ +""" +Test rooms API endpoints in the impress core app: retrieve. +""" +import random + +from django.test.utils import override_settings + +import pytest +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory + +pytestmark = pytest.mark.django_db + + +def test_api_rooms_retrieve_anonymous_private_pk(): + """ + Anonymous users should be allowed to retrieve a private room but should not be + given any token. + """ + room = RoomFactory(is_public=False) + client = APIClient() + response = client.get(f"/api/v1.0/rooms/{room.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": False, + "name": room.name, + "slug": room.slug, + } + + +def test_api_rooms_retrieve_anonymous_private_pk_no_dashes(): + """It should be possible to get a room by its id stripped of its dashes.""" + room = RoomFactory(is_public=False) + id_no_dashes = str(room.id).replace("-", "") + + client = APIClient() + response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": False, + "name": room.name, + "slug": room.slug, + } + + +def test_api_rooms_retrieve_anonymous_private_slug(): + """It should be possible to get a room by its slug.""" + room = RoomFactory(is_public=False) + client = APIClient() + response = client.get(f"/api/v1.0/rooms/{room.slug!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": False, + "name": room.name, + "slug": room.slug, + } + + +def test_api_rooms_retrieve_anonymous_private_slug_not_normalized(): + """Getting a room by a slug that is not normalized should work.""" + room = RoomFactory(name="Réunion", is_public=False) + client = APIClient() + response = client.get("/api/v1.0/rooms/Réunion/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": False, + "name": room.name, + "slug": room.slug, + } + + +@override_settings(ALLOW_UNREGISTERED_ROOMS=True) +def test_api_rooms_retrieve_anonymous_unregistered_allowed(): + """ + Retrieving an unregistered room should return a Livekit token + if unregistered rooms are allowed. + """ + client = APIClient() + response = client.get("/api/v1.0/rooms/unregistered-room/") + + assert response.status_code == 200 + assert response.json() == { + "id": None, + "livekit": { + "room": "unregistered-room", + "token": "foo", + }, + } + + # todo - assert generate_token has been called + + +@override_settings(ALLOW_UNREGISTERED_ROOMS=True) +def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(): + """ + Getting an unregistered room by a slug that is not normalized should work + and use the Livekit room on the url-safe name. + """ + client = APIClient() + response = client.get("/api/v1.0/rooms/Réunion/") + + assert response.status_code == 200 + assert response.json() == { + "id": None, + "livekit": { + "room": "reunion", + "token": "foo", + }, + } + + # todo - assert generate_token has been called + + +@override_settings(ALLOW_UNREGISTERED_ROOMS=False) +def test_api_rooms_retrieve_anonymous_unregistered_not_allowed(): + """ + Retrieving an unregistered room should return a 404 if unregistered rooms are not allowed. + """ + client = APIClient() + response = client.get("/api/v1.0/rooms/unregistered-room/") + + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +def test_api_rooms_retrieve_anonymous_public(): + """ + Anonymous users should be able to retrieve a room with a token provided it is public. + """ + room = RoomFactory(is_public=True) + client = APIClient() + response = client.get(f"/api/v1.0/rooms/{room.id!s}/") + + assert response.status_code == 200 + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": True, + "livekit": { + "room": "foo", + "token": "foo", + }, + "name": room.name, + "slug": room.slug, + } + + # todo - assert generate_token has been called + + +def test_api_rooms_retrieve_authenticated_public(): + """ + Authenticated users should be allowed to retrieve a room and get a token for a room to + which they are not related, provided the room is public. + They should not see related users. + """ + room = RoomFactory(is_public=True) + + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/rooms/{room.id!s}/", + ) + assert response.status_code == 200 + + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": True, + "livekit": { + "room": "foo", + "token": "foo", + }, + "name": room.name, + "slug": room.slug, + } + + # todo - assert generate_token has been called + + +def test_api_rooms_retrieve_authenticated(): + """ + Authenticated users should be allowed to retrieve a private room to which they + are not related but should not be given any token. + """ + room = RoomFactory(is_public=False) + + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/rooms/{room.id!s}/", + ) + assert response.status_code == 200 + + assert response.json() == { + "id": str(room.id), + "is_administrable": False, + "is_public": False, + "name": room.name, + "slug": room.slug, + } + + +def test_api_rooms_retrieve_members(django_assert_num_queries): + """ + Users who are members of a room should be allowed to see related users. + """ + user = UserFactory() + other_user = UserFactory() + + room = RoomFactory() + user_access = UserResourceAccessFactory(resource=room, user=user, role="member") + other_user_access = UserResourceAccessFactory( + resource=room, user=other_user, role="member" + ) + + client = APIClient() + client.force_login(user) + + with django_assert_num_queries(4): + response = client.get( + f"/api/v1.0/rooms/{room.id!s}/", + ) + + assert response.status_code == 200 + content_dict = response.json() + + assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(user_access.id), + "user": { + "id": str(user_access.user.id), + "email": user_access.user.email, + }, + "resource": str(room.id), + "role": user_access.role, + }, + { + "id": str(other_user_access.id), + "user": { + "id": str(other_user_access.user.id), + "email": other_user_access.user.email, + }, + "resource": str(room.id), + "role": other_user_access.role, + }, + ], + key=lambda x: x["id"], + ) + + assert content_dict == { + "id": str(room.id), + "is_administrable": False, + "is_public": room.is_public, + "livekit": { + "room": "foo", + "token": "foo", + }, + "name": room.name, + "slug": room.slug, + } + + # todo - assert generate_token has been called + + +def test_api_rooms_retrieve_administrators(django_assert_num_queries): + """ + A user who is an administrator or owner of a room should be allowed + to see related users. + """ + user = UserFactory() + other_user = UserFactory() + room = RoomFactory() + user_access = UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + other_user_access = UserResourceAccessFactory( + resource=room, user=other_user, role="member" + ) + client = APIClient() + client.force_login(user) + + with django_assert_num_queries(4): + response = client.get( + f"/api/v1.0/rooms/{room.id!s}/", + ) + assert response.status_code == 200 + content_dict = response.json() + + assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted( + [ + { + "id": str(other_user_access.id), + "user": { + "id": str(other_user_access.user.id), + "email": other_user_access.user.email, + }, + "resource": str(room.id), + "role": other_user_access.role, + }, + { + "id": str(user_access.id), + "user": { + "id": str(user_access.user.id), + "email": user_access.user.email, + }, + "resource": str(room.id), + "role": user_access.role, + }, + ], + key=lambda x: x["id"], + ) + + assert content_dict == { + "id": str(room.id), + "is_administrable": True, + "is_public": room.is_public, + "configuration": {}, + "livekit": { + "room": "foo", + "token": "foo", + }, + "name": room.name, + "slug": room.slug, + } + + # todo - assert generate_token has been called diff --git a/src/backend/core/tests/rooms/test_api_rooms_update.py b/src/backend/core/tests/rooms/test_api_rooms_update.py new file mode 100644 index 00000000..bc83658e --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_update.py @@ -0,0 +1,123 @@ +""" +Test rooms API endpoints in the impress core app: update. +""" +import random + +import pytest +from rest_framework.test import APIClient + +from ...factories import RoomFactory, UserFactory + +pytestmark = pytest.mark.django_db + + +def test_api_rooms_update_anonymous(): + """Anonymous users should not be allowed to update a room.""" + room = RoomFactory(name="Old name") + client = APIClient() + + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + }, + ) + assert response.status_code == 401 + room.refresh_from_db() + assert room.name == "Old name" + assert room.slug == "old-name" + + +def test_api_rooms_update_authenticated(): + """Authenticated users should not be allowed to update a room.""" + user = UserFactory() + room = RoomFactory(name="Old name") + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + }, + ) + assert response.status_code == 403 + room.refresh_from_db() + assert room.name == "Old name" + assert room.slug == "old-name" + + +def test_api_rooms_update_members(): + """ + Users who are members of a room but not administrators should + not be allowed to update it. + """ + user = UserFactory() + room = RoomFactory(name="Old name", users=[(user, "member")]) + client = APIClient() + client.force_login(user) + + new_is_public = not room.is_public + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + "slug": "should-be-ignored", + "is_public": new_is_public, + "configuration": {"the_key": "the_value"}, + }, + format="json", + ) + assert response.status_code, 403 + room.refresh_from_db() + assert room.name == "Old name" + assert room.slug == "old-name" + assert room.is_public != new_is_public + assert room.configuration == {} + + +def test_api_rooms_update_administrators(): + """Administrators or owners of a room should be allowed to update it.""" + user = UserFactory() + room = RoomFactory(users=[(user, random.choice(["administrator", "owner"]))]) + client = APIClient() + client.force_login(user) + + new_is_public = not room.is_public + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + "slug": "should-be-ignored", + "is_public": new_is_public, + "configuration": {"the_key": "the_value"}, + }, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.name == "New name" + assert room.slug == "new-name" + assert room.is_public == new_is_public + assert room.configuration == {"the_key": "the_value"} + + +def test_api_rooms_update_administrators_of_another(): + """ + Being administrator or owner of a room should not grant authorization to update + another room. + """ + user = UserFactory() + RoomFactory(users=[(user, random.choice(["administrator", "owner"]))]) + other_room = RoomFactory(name="Old name") + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/rooms/{other_room.id!s}/", + {"name": "New name", "slug": "should-be-ignored"}, + ) + assert response.status_code, 403 + other_room.refresh_from_db() + assert other_room.name == "Old name" + assert other_room.slug == "old-name" diff --git a/src/backend/core/tests/test_api_resource_accesses.py b/src/backend/core/tests/test_api_resource_accesses.py new file mode 100644 index 00000000..2653c495 --- /dev/null +++ b/src/backend/core/tests/test_api_resource_accesses.py @@ -0,0 +1,953 @@ +""" +Test resource accesses API endpoints in the impress core app. +""" +import random +from unittest import mock +from uuid import uuid4 + +import pytest +from rest_framework.pagination import PageNumberPagination +from rest_framework.test import APIClient + +from ..api.serializers import ResourceAccessSerializer +from ..factories import ( + RoomFactory, + UserFactory, + UserResourceAccessFactory, +) +from ..models import ResourceAccess, RoleChoices + +pytestmark = pytest.mark.django_db + + +def test_api_room_user_accesses_list_anonymous(): + """Anonymous users should not be allowed to list room user accesses.""" + + UserResourceAccessFactory() + client = APIClient() + + response = client.get("/api/v1.0/resource-accesses/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_room_user_accesses_list_authenticated_not_related(): + """ + Authenticated users should not be allowed to list room user accesses for a room + to which they are not related, be it public or private. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + public_room = RoomFactory(is_public=True) + UserResourceAccessFactory(resource=public_room) + UserResourceAccessFactory(resource=public_room, role="member") + UserResourceAccessFactory(resource=public_room, role="administrator") + UserResourceAccessFactory(resource=public_room, role="owner") + + private_room = RoomFactory(is_public=False) + UserResourceAccessFactory(resource=private_room) + UserResourceAccessFactory(resource=private_room, role="member") + UserResourceAccessFactory(resource=private_room, role="administrator") + UserResourceAccessFactory(resource=private_room, role="owner") + + response = client.get( + "/api/v1.0/resource-accesses/", + ) + assert response.status_code == 200 + assert response.json()["results"] == [] + + +def test_api_room_user_accesses_list_authenticated_member(): + """ + Authenticated users should not be allowed to list room user accesses for a room + in which they are a simple member. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + public_room = RoomFactory(is_public=True, users=[(user, "member")]) + UserResourceAccessFactory(resource=public_room) + UserResourceAccessFactory(resource=public_room, role="member") + UserResourceAccessFactory(resource=public_room, role="administrator") + UserResourceAccessFactory(resource=public_room, role="owner") + + private_room = RoomFactory(is_public=False, users=[(user, "member")]) + UserResourceAccessFactory(resource=private_room) + UserResourceAccessFactory(resource=private_room, role="member") + UserResourceAccessFactory(resource=private_room, role="administrator") + UserResourceAccessFactory(resource=private_room, role="owner") + + response = client.get( + "/api/v1.0/resource-accesses/", + ) + assert response.status_code == 200 + assert response.json()["results"] == [] + + +def test_api_room_user_accesses_list_authenticated_administrator(): + """ + Authenticated users should be allowed to list room user accesses for a room + in which they are an administrator. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + public_room = RoomFactory(is_public=True) + public_room_accesses = ( + # Access for the logged-in user + UserResourceAccessFactory( + resource=public_room, user=user, role="administrator" + ), + # Accesses for other users + UserResourceAccessFactory(resource=public_room), + UserResourceAccessFactory(resource=public_room, role="member"), + UserResourceAccessFactory(resource=public_room, role="administrator"), + UserResourceAccessFactory(resource=public_room, role="owner"), + ) + + private_room = RoomFactory(is_public=False) + private_room_accesses = ( + # Access for the logged-in user + UserResourceAccessFactory( + resource=private_room, user=user, role="administrator" + ), + # Accesses for other users + UserResourceAccessFactory(resource=private_room), + UserResourceAccessFactory(resource=private_room, role="member"), + UserResourceAccessFactory(resource=private_room, role="administrator"), + UserResourceAccessFactory(resource=private_room, role="owner"), + ) + response = client.get( + "/api/v1.0/resource-accesses/", + ) + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 10 + assert [item["id"] for item in results].sort() == [ + str(access.id) for access in public_room_accesses + private_room_accesses + ].sort() + + +def test_api_room_user_accesses_list_authenticated_owner(): + """ + Authenticated users should be allowed to list room user accesses for a room + in which they are owner. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + public_room = RoomFactory(is_public=True) + public_room_accesses = ( + # Access for the logged-in user + UserResourceAccessFactory(resource=public_room, user=user, role="owner"), + # Accesses for other users + UserResourceAccessFactory(resource=public_room), + UserResourceAccessFactory(resource=public_room, role="member"), + UserResourceAccessFactory(resource=public_room, role="administrator"), + UserResourceAccessFactory(resource=public_room, role="owner"), + ) + private_room = RoomFactory(is_public=False) + private_room_accesses = ( + # Access for the logged-in user + UserResourceAccessFactory(resource=private_room, user=user, role="owner"), + # Accesses for other users + UserResourceAccessFactory(resource=private_room), + UserResourceAccessFactory(resource=private_room, role="member"), + UserResourceAccessFactory(resource=private_room, role="administrator"), + UserResourceAccessFactory(resource=private_room, role="owner"), + ) + response = client.get("/api/v1.0/resource-accesses/") + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 10 + assert [item["id"] for item in results].sort() == [ + str(access.id) for access in public_room_accesses + private_room_accesses + ].sort() + + +@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2) +def test_api_room_user_accesses_list_pagination(_mock_page_size): + """Pagination should work as expected.""" + + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory() + accesses = [ + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ), + *UserResourceAccessFactory.create_batch(2, resource=room), + ] + access_ids = [str(access.id) for access in accesses] + + response = client.get( + "/api/v1.0/resource-accesses/", + ) + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] == "http://testserver/api/v1.0/resource-accesses/?page=2" + assert content["previous"] is None + + assert len(content["results"]) == 2 + for item in content["results"]: + access_ids.remove(item["id"]) + + # Get page 2 + response = client.get("/api/v1.0/resource-accesses/?page=2") + + assert response.status_code == 200 + content = response.json() + + assert content["count"] == 3 + assert content["next"] is None + assert content["previous"] == "http://testserver/api/v1.0/resource-accesses/" + assert len(content["results"]) == 1 + access_ids.remove(content["results"][0]["id"]) + assert access_ids == [] + + +# Retrieve + + +def test_api_room_user_accesses_retrieve_anonymous(): + """ + Anonymous users should not be allowed to retrieve a room user access. + """ + access = UserResourceAccessFactory() + client = APIClient() + + response = client.get( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_room_user_accesses_retrieve_authenticated_not_related(): + """ + Authenticated users should not be allowed to retrieve a room user access for + a room to which they are not related, be it public or private. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + for is_public in [True, False]: + room = RoomFactory(is_public=is_public) + assert len(RoleChoices.choices) == 3 + + for role, _name in RoleChoices.choices: + access = UserResourceAccessFactory(resource=room, role=role) + response = client.get( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_room_user_accesses_retrieve_authenticated_member(): + """ + Authenticated users should not be allowed to retrieve a room user access for a room in + which they are a simple member, be it public or private. + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + for is_public in [True, False]: + room = RoomFactory( + is_public=is_public, + users=[(user, "member")], + ) + assert len(RoleChoices.choices) == 3 + + for role, _name in RoleChoices.choices: + access = UserResourceAccessFactory(resource=room, role=role) + response = client.get( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_room_user_accesses_retrieve_authenticated_administrator(): + """ + A user who is an administrator of a room should be allowed to retrieve the + associated room user accesses + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + for is_public in [True, False]: + room = RoomFactory(is_public=is_public, users=[(user, "administrator")]) + assert len(RoleChoices.choices) == 3 + + for role, _name in RoleChoices.choices: + access = UserResourceAccessFactory(resource=room, role=role) + response = client.get( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "resource": str(access.resource_id), + "role": access.role, + } + + +def test_api_room_user_accesses_retrieve_authenticated_owner(): + """ + A user who is an owner of a room should be allowed to retrieve the + associated room user accesses + """ + user = UserFactory() + + client = APIClient() + client.force_login(user) + + for is_public in [True, False]: + room = RoomFactory(is_public=is_public, users=[(user, "owner")]) + assert len(RoleChoices.choices) == 3 + + for role, _name in RoleChoices.choices: + access = UserResourceAccessFactory(resource=room, role=role) + response = client.get( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(access.id), + "user": str(access.user.id), + "resource": str(access.resource_id), + "role": access.role, + } + + +# Create + + +def test_api_room_user_accesses_create_anonymous(): + """Anonymous users should not be allowed to create room user accesses.""" + user = UserFactory() + room = RoomFactory() + + client = APIClient() + + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(user.id), + "resource": str(room.id), + "role": random.choice(["member", "administrator", "owner"]), + }, + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert ResourceAccess.objects.exists() is False + + +def test_api_room_user_accesses_create_authenticated(): + """Authenticated users should not be allowed to create room user accesses.""" + user, other_user = UserFactory.create_batch(2) + room = RoomFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(other_user.id), + "resource": str(room.id), + "role": random.choice(["member", "administrator", "owner"]), + }, + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You must be administrator or owner of a room to add accesses to it." + } + assert ResourceAccess.objects.filter(user=other_user).exists() is False + + +def test_api_room_user_accesses_create_members(): + """ + A user who is a simple member in a room should not be allowed to create + room user accesses in this room. + """ + user, other_user = UserFactory.create_batch(2) + room = RoomFactory(users=[(user, "member")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(other_user.id), + "resource": str(room.id), + "role": random.choice(["member", "administrator", "owner"]), + }, + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "You must be administrator or owner of a room to add accesses to it." + } + assert ResourceAccess.objects.filter(user=other_user).exists() is False + + +def test_api_room_user_accesses_create_administrators_except_owner(): + """ + A user who is administrator in a room should be allowed to create + room user accesses in this room for roles other than owner (which is tested in the + subsequent test). + """ + user, other_user = UserFactory.create_batch(2) + room = RoomFactory(users=[(user, "administrator")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(other_user.id), + "resource": str(room.id), + "role": random.choice(["member", "administrator"]), + }, + ) + assert response.status_code == 201 + assert ResourceAccess.objects.count() == 2 + assert ResourceAccess.objects.filter(user=other_user).exists() is True + + +def test_api_room_user_accesses_create_administrators_owner(): + """ + A user who is administrator in a room should not be allowed to create room + user accesses in this room for the owner role. + """ + user, other_user = UserFactory.create_batch(2) + room = RoomFactory(users=[(user, "administrator")]) + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(other_user.id), + "resource": str(room.id), + "role": "owner", + }, + ) + assert response.status_code == 403 + assert ResourceAccess.objects.filter(user=other_user).exists() is False + + +def test_api_room_user_accesses_create_owner_all_roles(): + """ + A user who is owner in a room should be allowed to create + room user accesses in this room for all roles. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + + client = APIClient() + client.force_login(user) + + for i, role in enumerate(["member", "administrator", "owner"]): + other_user = UserFactory() + response = client.post( + "/api/v1.0/resource-accesses/", + { + "user": str(other_user.id), + "resource": str(room.id), + "role": role, + }, + ) + + assert response.status_code == 201 + assert ResourceAccess.objects.count() == i + 2 + assert ResourceAccess.objects.filter(user=other_user).exists() is True + + +# Update + + +def test_api_room_user_accesses_update_anonymous(): + """Anonymous users should not be allowed to update a room user access.""" + access = UserResourceAccessFactory() + old_values = ResourceAccessSerializer(instance=access).data + + client = APIClient() + + new_values = { + "id": uuid4(), + "resource": RoomFactory().id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 401 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_authenticated(): + """Authenticated users should not be allowed to update a room user access.""" + user = UserFactory() + client = APIClient() + client.force_login(user) + + access = UserResourceAccessFactory() + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "member")]).id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_member(): + """ + A user who is a simple member in a room should not be allowed to update + a user access for this room. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "member")]) + access = UserResourceAccessFactory(resource=room) + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "member")]).id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_administrator_except_owner(): + """ + A user who is an administrator in a room should be allowed to update + a user access for this room, as long as she does not try to set the role to owner. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "administrator")]) + access = UserResourceAccessFactory( + resource=room, role=random.choice(["member", "administrator"]) + ) + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "administrator")]).id, + "user": UserFactory().id, + "role": random.choice(["member", "administrator"]), + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code, 200 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_room_user_accesses_update_administrator_from_owner(): + """ + A user who is an administrator in a room, should not be allowed to update + the user access of an owner for this room. + """ + user, other_user = UserFactory.create_batch(2) + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "administrator")]) + access = UserResourceAccessFactory(resource=room, user=other_user, role="owner") + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "administrator")]).id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_administrator_to_owner(): + """ + A user who is an administrator in a room should not be allowed to update + the user access of another user when granting ownership. + """ + user, other_user = UserFactory.create_batch(2) + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "administrator")]) + access = UserResourceAccessFactory( + resource=room, + user=other_user, + role=random.choice(["member", "administrator"]), + ) + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "administrator")]).id, + "user": UserFactory().id, + "role": "owner", + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + if field == "role": + assert response.status_code == 403 + else: + assert response.status_code == 200 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_owner_except_owner(): + """ + A user who is an owner in a room should be allowed to update + a user access for this room except for existing owner accesses. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "owner")]) + access = UserResourceAccessFactory( + resource=room, role=random.choice(["member", "administrator"]) + ) + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "administrator")]).id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + + if field == "role": + assert updated_values == {**old_values, "role": new_values["role"]} + else: + assert updated_values == old_values + + +def test_api_room_user_accesses_update_owner_for_owners(): + """ + A user who is an owner in a room should not be allowed to update + an existing owner user access for this room. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory(users=[(user, "owner")]) + access = UserResourceAccessFactory(resource=room, role="owner") + old_values = ResourceAccessSerializer(instance=access).data + + new_values = { + "id": uuid4(), + "resource": RoomFactory(users=[(user, "administrator")]).id, + "user": UserFactory().id, + "role": random.choice(RoleChoices.choices)[0], + } + for field, value in new_values.items(): + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, field: value}, + format="json", + ) + assert response.status_code == 403 + access.refresh_from_db() + updated_values = ResourceAccessSerializer(instance=access).data + assert updated_values == old_values + + +def test_api_room_user_accesses_update_owner_self(): + """ + A user who is an owner in a room should be allowed to update + her own user access provided there are other owners in the room. + """ + user = UserFactory() + client = APIClient() + client.force_login(user) + + room = RoomFactory() + access = UserResourceAccessFactory(resource=room, user=user, role="owner") + old_values = ResourceAccessSerializer(instance=access).data + new_role = random.choice(["member", "administrator"]) + + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 403 + access.refresh_from_db() + assert access.role == "owner" + + # Add another owner and it should now work + UserResourceAccessFactory(resource=room, role="owner") + + response = client.put( + f"/api/v1.0/resource-accesses/{access.id!s}/", + {**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + +# Delete + + +def test_api_room_user_access_delete_anonymous(): + """Anonymous users should not be allowed to destroy a room user access.""" + access = UserResourceAccessFactory() + client = APIClient() + + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 401 + assert ResourceAccess.objects.count() == 1 + + +def test_api_room_user_access_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a room user access for a room in + which they are not administrator. + """ + access = UserResourceAccessFactory() + user = UserFactory() + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert ResourceAccess.objects.count() == 1 + + +def test_api_room_user_access_delete_members(): + """ + Authenticated users should not be allowed to delete a room user access for a room in + which they are a simple member. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "member")]) + access = UserResourceAccessFactory(resource=room) + + client = APIClient() + client.force_login(user) + + assert ResourceAccess.objects.count() == 2 + assert ResourceAccess.objects.filter(user=access.user).exists() is True + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert ResourceAccess.objects.count() == 2 + + +def test_api_room_user_access_delete_administrators(): + """ + Users who are administrators in a room should be allowed to delete a user access + from the room provided it is not ownership. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "administrator")]) + access = UserResourceAccessFactory( + resource=room, role=random.choice(["member", "administrator"]) + ) + + client = APIClient() + client.force_login(user) + + assert ResourceAccess.objects.count() == 2 + assert ResourceAccess.objects.filter(user=access.user).exists() is True + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert ResourceAccess.objects.count() == 1 + + +def test_api_room_user_access_delete_owners_except_owners(): + """ + Users should be able to delete the room user access of another user + for a room in which they are owner except for owners. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + access = UserResourceAccessFactory( + resource=room, role=random.choice(["member", "administrator"]) + ) + + client = APIClient() + client.force_login(user) + + assert ResourceAccess.objects.count() == 2 + assert ResourceAccess.objects.filter(user=access.user).exists() is True + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert ResourceAccess.objects.count() == 1 + + +def test_api_room_user_access_delete_owners_for_owners(): + """ + Users should not be able to delete the room user access of another owner + even for a room in which they are owners. + """ + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + access = UserResourceAccessFactory(resource=room, role="owner") + + client = APIClient() + client.force_login(user) + + assert ResourceAccess.objects.count() == 2 + assert ResourceAccess.objects.filter(user=access.user).exists() is True + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert ResourceAccess.objects.count() == 2 + + +def test_api_room_user_access_delete_owners_last_owner(): + """ + It should not be possible to delete the last owner access from a room + """ + user = UserFactory() + room = RoomFactory() + access = UserResourceAccessFactory(resource=room, user=user, role="owner") + + client = APIClient() + client.force_login(user) + + assert ResourceAccess.objects.count() == 1 + response = client.delete( + f"/api/v1.0/resource-accesses/{access.id!s}/", + ) + + assert response.status_code == 403 + assert ResourceAccess.objects.count() == 1 diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2fc50280..51e360c1 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -10,6 +10,10 @@ from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints router = DefaultRouter() router.register("users", viewsets.UserViewSet, basename="users") +router.register("rooms", viewsets.RoomViewSet, basename="rooms") +router.register( + "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" +) urlpatterns = [ path( diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 05195a97..c77e7e0e 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -372,6 +372,9 @@ class Base(Configuration): DEFAULT_ROOM_IS_PUBLIC = values.BooleanValue( True, environ_name="MAGNIFY_DEFAULT_ROOM_IS_PUBLIC", environ_prefix=None ) + ALLOW_UNREGISTERED_ROOMS = values.BooleanValue( + True, environ_name="MAGNIFY_ALLOW_UNREGISTERED_ROOMS", environ_prefix=None + ) # pylint: disable=invalid-name @property