From 9d6ed7aa21e2cd985e5c036e77d1f34c67b8a6de Mon Sep 17 00:00:00 2001 From: leo <260626284+cameledev@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:38:06 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20update=20a=20room's=20attr?= =?UTF-8?q?ibutes=20from=20the=20external=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update a room's access level and/or configuration using PATCH from the external API. Log modifications and send to analytics. --- CHANGELOG.md | 1 + docs/openapi.yaml | 81 ++- docs/resource_server.yaml | 77 ++- src/backend/core/analytics/events.py | 1 + src/backend/core/api/viewsets.py | 27 +- src/backend/core/external_api/viewsets.py | 85 ++- src/backend/core/factories.py | 4 - .../recording/services/recording_events.py | 2 +- src/backend/core/recording/worker/mediator.py | 2 +- src/backend/core/services/livekit_events.py | 2 +- src/backend/core/services/room_management.py | 35 +- src/backend/core/tasks/connection_test.py | 2 +- .../core/tests/rooms/test_api_rooms_update.py | 35 +- .../tests/services/test_room_management.py | 27 +- .../core/tests/test_external_api_rooms.py | 640 +++++++++++++++++- 15 files changed, 926 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 873f4706..e7df657c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨(frontend) add 1080p sending resolution option #1660 - ✨(backend) add Traefik support via configurable media-auth url header #1649 +- ✨(backend) update a room's attributes from the external API ### Fixed diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7706ce16..108f2e13 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -16,11 +16,11 @@ info: * `rooms:list` – List rooms accessible to the delegated user. * `rooms:retrieve` – Retrieve details of a specific room. * `rooms:create` – Create new rooms. - * `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room. + * `rooms:update` – Update the access level and configuration of existing rooms. * `rooms:delete` – **Coming soon** Delete rooms generated by the application. - + #### Upcoming Features - + * **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically. * **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed. @@ -310,6 +310,67 @@ paths: '404': $ref: '#/components/responses/RoomNotFoundError' + patch: + tags: + - Rooms + summary: Update a room + description: | + Partially updates a room. Only the delegated user's rooms where they are + administrator or owner can be updated; any other role gets a `403`. + + **Updatable fields:** `access_level` and `configuration`. Every other field + (`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent. + + `configuration` is replaced as a whole, it is not merged with the stored one. + Send the complete object you want the room to end up with. + + Full replacement (`PUT`) is not supported. Use `PATCH` instead. + operationId: updateRoom + security: + - BearerAuth: [rooms:update] + parameters: + - name: id + in: path + required: true + description: Room UUID + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoomUpdate' + examples: + accessLevelOnly: + summary: Change the access level + value: + access_level: "restricted" + configurationOnly: + summary: Replace the room configuration + value: + configuration: + everyone_can_mute: true + responses: + '200': + description: Room updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Room' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/RoomNotFoundError' + '405': + description: | + Method not allowed, `PUT` is not supported on this endpoint. + components: securitySchemes: BearerAuth: @@ -386,6 +447,17 @@ components: configuration: $ref: '#/components/schemas/RoomConfiguration' + RoomUpdate: + type: object + description: | + Fields that can be updated on an existing room. Both are optional, omitted + fields keep their current value. + properties: + access_level: + $ref: '#/components/schemas/RoomAccessLevel' + configuration: + $ref: '#/components/schemas/RoomConfiguration' + RoomConfiguration: type: object description: | @@ -427,6 +499,9 @@ components: - `public`: Anyone with the room link can join directly, no authentication required. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. + + `public` is rejected with a `400` unless the deployment explicitly enables it + for this API. This applies both when creating a room and when updating one. example: "trusted" Room: diff --git a/docs/resource_server.yaml b/docs/resource_server.yaml index f6ab02a5..c6b310d3 100644 --- a/docs/resource_server.yaml +++ b/docs/resource_server.yaml @@ -20,7 +20,7 @@ info: * `lasuite_visio:rooms:list` – List rooms accessible to the delegated user. * `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room. * `lasuite_visio:rooms:create` – Create new rooms. - * `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room. + * `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms. * `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application. #### Upcoming Features @@ -206,6 +206,67 @@ paths: '404': $ref: '#/components/responses/RoomNotFoundError' + patch: + tags: + - Rooms + summary: Update a room + description: | + Partially updates a room. Only rooms where the user is administrator or + owner can be updated; any other role gets a `403`. + + **Updatable fields:** `access_level` and `configuration`. Every other field + (`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent. + + `configuration` is replaced as a whole, it is not merged with the stored one. + Send the complete object you want the room to end up with. + + Full replacement (`PUT`) is not supported. Use `PATCH` instead. + operationId: updateRoom + security: + - BearerAuth: [rooms:update] + parameters: + - name: id + in: path + required: true + description: Room UUID + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoomUpdate' + examples: + accessLevelOnly: + summary: Change the access level + value: + access_level: "restricted" + configurationOnly: + summary: Replace the room configuration + value: + configuration: + everyone_can_mute: true + responses: + '200': + description: Room updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Room' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/RoomNotFoundError' + '405': + description: | + Method not allowed, `PUT` is not supported on this endpoint. + components: securitySchemes: BearerAuth: @@ -227,6 +288,17 @@ components: configuration: $ref: '#/components/schemas/RoomConfiguration' + RoomUpdate: + type: object + description: | + Fields that can be updated on an existing room. Both are optional, omitted + fields keep their current value. + properties: + access_level: + $ref: '#/components/schemas/RoomAccessLevel' + configuration: + $ref: '#/components/schemas/RoomConfiguration' + RoomConfiguration: type: object description: | @@ -268,6 +340,9 @@ components: - `public`: Anyone with the room link can join directly, no authentication required. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. + + `public` is rejected with a `400` unless the deployment explicitly enables it + for this API. This applies both when creating a room and when updating one. example: "trusted" Room: diff --git a/src/backend/core/analytics/events.py b/src/backend/core/analytics/events.py index 49d5a6bc..c7c1bc41 100644 --- a/src/backend/core/analytics/events.py +++ b/src/backend/core/analytics/events.py @@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum): # Rooms ROOM_CREATED = "room_created" + ROOM_UPDATED = "room_updated" # Roomkit (meeting-room SIP devices) ROOMKIT_JOINED = "roomkit_joined" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5df32ab9..9cd7f49a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -75,11 +75,7 @@ from core.services.participants_management import ( ParticipantsManagementException, ) from core.services.room_creation import RoomCreation -from core.services.room_management import ( - RoomManagement, - RoomManagementException, - RoomNotFoundException, -) +from core.services.room_management import RoomManagement from core.services.room_roles import ( RoomRoleError, RoomRoleService, @@ -356,26 +352,7 @@ class RoomViewSet( ): return - metadata = { - "configuration": room.configuration, - "access_level": room.access_level, - } - - try: - RoomManagement().update_metadata( - room_name=str(room.id), - metadata=metadata, - ) - except RoomNotFoundException: - logger.info( - "LiveKit room %s does not exist yet, skipping metadata sync", - room.id, - ) - except RoomManagementException: - logger.warning( - "Failed to sync metadata to LiveKit for room %s", - room.id, - ) + RoomManagement.sync_room_metadata(room) @decorators.action( detail=True, diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 733acd39..d61d095f 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -1,5 +1,6 @@ """External API endpoints""" +import copy from logging import getLogger from django.conf import settings @@ -25,6 +26,7 @@ from rest_framework import ( from core import analytics, api, models from core.api.feature_flag import FeatureFlag from core.services.jwt_token import JwtTokenService +from core.services.room_management import RoomManagement from ..services.provisional_user_service import ( ProvisionalUserCreationDisabledError, @@ -142,6 +144,7 @@ class RoomViewSet( mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, + mixins.UpdateModelMixin, viewsets.GenericViewSet, ): """Application-delegated API for room management. @@ -154,8 +157,12 @@ class RoomViewSet( - list: List rooms the user has access to (requires 'rooms:list' scope) - retrieve: Get room details (requires 'rooms:retrieve' scope) - create: Create a new room owned by the user (requires 'rooms:create' scope) + - partial_update: Update a room's access level and configuration, for + administrators and owners only (requires 'rooms:update' scope) """ + http_method_names = ["get", "post", "patch", "head", "options"] + authentication_classes = [ authentication.ApplicationJWTAuthentication, authentication.AddonsJWTAuthentication, @@ -189,7 +196,39 @@ class RoomViewSet( serializer = self.get_serializer(queryset, many=True) return drf_response.Response(serializer.data) - def perform_create(self, serializer): + def _track_room_event(self, room, event, **extra_properties): + """Log a room operation for auditing and forward it to analytics.""" + + auth_method = type(self.request.successful_authenticator).__name__ + client_id = (self.request.auth or {}).get("client_id", "unknown") + + # Log for auditing + details = "".join(f", {key}={value}" for key, value in extra_properties.items()) + logger.info( + "Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s", + event.removeprefix("room_"), + room.id, + self.request.user.id, + client_id, + auth_method, + details, + ) + + analytics.capture( + self.request.user, + event, + { + "room_id": str(room.pk), + "access_level": room.access_level, + "client_id": client_id, + "external_api": True, + "auth_method": auth_method, + **extra_properties, + "$set": {"email": self.request.user.email}, + }, + ) + + def perform_create(self, serializer: serializers.RoomSerializer): """Set the current user as owner of the newly created room.""" room = serializer.save() models.ResourceAccess.objects.create( @@ -198,27 +237,31 @@ class RoomViewSet( role=models.RoleChoices.OWNER, ) - auth_method = type(self.request.successful_authenticator).__name__ - client_id = (self.request.auth or {}).get("client_id", "unknown") + self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED) - # Log for auditing - logger.info( - "Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s", - room.id, - self.request.user.id, - client_id, - auth_method, + def perform_update(self, serializer: serializers.RoomSerializer): + """Persist the room update, sync it to LiveKit, then log and track it.""" + + previous_values = { + "access_level": serializer.instance.access_level, + "configuration": copy.deepcopy(serializer.instance.configuration), + } + + room = serializer.save() + + # Report the fields that actually changed, not the ones that were submitted. + updated_fields = sorted( + field + for field, previous_value in previous_values.items() + if getattr(room, field) != previous_value ) - analytics.capture( - self.request.user, - analytics.AnalyticsEvent.ROOM_CREATED, - { - "room_id": str(room.pk), - "access_level": room.access_level, - "client_id": client_id, - "external_api": True, - "auth_method": auth_method, - "$set": {"email": self.request.user.email}, - }, + if updated_fields: + RoomManagement.sync_room_metadata(room) + + self._track_room_event( + room, + analytics.AnalyticsEvent.ROOM_UPDATED, + updated_fields=updated_fields, + previous_access_level=previous_values["access_level"], ) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index a50f1b23..60536c6c 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -48,8 +48,6 @@ class ResourceFactory(factory.django.DjangoModelFactory): else: UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) - self.save() - class UserResourceAccessFactory(factory.django.DjangoModelFactory): """Create fake resource user accesses for testing.""" @@ -97,8 +95,6 @@ class RecordingFactory(factory.django.DjangoModelFactory): recording=self, user=item[0], role=item[1] ) - self.save() - class UserRecordingAccessFactory(factory.django.DjangoModelFactory): """Create fake recording user accesses for testing.""" diff --git a/src/backend/core/recording/services/recording_events.py b/src/backend/core/recording/services/recording_events.py index 3b11315f..0151c052 100644 --- a/src/backend/core/recording/services/recording_events.py +++ b/src/backend/core/recording/services/recording_events.py @@ -44,7 +44,7 @@ class RecordingEventsService: recording_status = status_mapping.get(egress_status) if recording_status: try: - RoomManagement().update_metadata( + RoomManagement.update_metadata( room_name, {"recording_status": recording_status} ) except RoomNotFoundException: diff --git a/src/backend/core/recording/worker/mediator.py b/src/backend/core/recording/worker/mediator.py index 2de8be05..707a88f2 100644 --- a/src/backend/core/recording/worker/mediator.py +++ b/src/backend/core/recording/worker/mediator.py @@ -68,7 +68,7 @@ class WorkerServiceMediator: mode = recording.options.get("original_mode", None) or recording.mode try: - RoomManagement().update_metadata( + RoomManagement.update_metadata( room_name, {"recording_mode": mode, "recording_status": "starting"} ) except RoomNotFoundException: diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index 3f396e6f..8534647e 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -192,7 +192,7 @@ class LiveKitEventsService: try: room_name = str(recording.room.id) - RoomManagement().update_metadata( + RoomManagement.update_metadata( room_name, remove_keys=["recording_mode", "recording_status"] ) except RoomNotFoundException: diff --git a/src/backend/core/services/room_management.py b/src/backend/core/services/room_management.py index 163af6e2..1a55dafb 100644 --- a/src/backend/core/services/room_management.py +++ b/src/backend/core/services/room_management.py @@ -30,9 +30,10 @@ class RoomNotFoundException(RoomManagementException): class RoomManagement: """Service for managing LiveKit rooms.""" + @classmethod @async_to_sync async def update_metadata( - self, + cls, room_name: str, metadata: Optional[Dict] = None, remove_keys: Optional[list[str]] = None, @@ -90,8 +91,9 @@ class RoomManagement: finally: await lkapi.aclose() + @classmethod @async_to_sync - async def delete_room(self, room_name: str): + async def delete_room(cls, room_name: str): """Delete a LiveKit room and disconnect all participants. Raises: @@ -116,3 +118,32 @@ class RoomManagement: raise RoomManagementException("Could not delete room") from e finally: await lkapi.aclose() + + @classmethod + def sync_room_metadata(cls, room): + """Push a room's configuration and access level to its LiveKit room metadata. + + Failures are swallowed: a room that is not live yet, or a LiveKit hiccup, + should never fail the request that triggered the update. + """ + + metadata = { + "configuration": room.configuration, + "access_level": room.access_level, + } + + try: + cls.update_metadata( + room_name=str(room.id), + metadata=metadata, + ) + except RoomNotFoundException: + logger.info( + "LiveKit room %s does not exist yet, skipping metadata sync", + room.id, + ) + except RoomManagementException: + logger.warning( + "Failed to sync metadata to LiveKit for room %s", + room.id, + ) diff --git a/src/backend/core/tasks/connection_test.py b/src/backend/core/tasks/connection_test.py index ba8872d6..9b40fd00 100644 --- a/src/backend/core/tasks/connection_test.py +++ b/src/backend/core/tasks/connection_test.py @@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str): return try: - RoomManagement().delete_room(room_name) + RoomManagement.delete_room(room_name) except RoomNotFoundException: # Room may already be gone after empty/departure timeout. logger.info("Connection test room '%s' already gone.", room_name) diff --git a/src/backend/core/tests/rooms/test_api_rooms_update.py b/src/backend/core/tests/rooms/test_api_rooms_update.py index 7d19ff80..196bd232 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_update.py +++ b/src/backend/core/tests/rooms/test_api_rooms_update.py @@ -381,38 +381,11 @@ def test_api_rooms_update_administrators_of_another(): assert other_room.slug == "old-name" -@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException) -def test_api_rooms_update_livekit_room_not_found(mock_update_metadata): - """Should not fail the API request when the LiveKit room does not exist yet.""" - user = UserFactory() - room = RoomFactory( - users=[(user, random.choice(["administrator", "owner"]))], - configuration={}, - ) - client = APIClient() - client.force_login(user) - - response = client.patch( - f"/api/v1.0/rooms/{room.id!s}/", - {"configuration": {"can_publish_sources": ["camera"]}}, - format="json", - ) - assert response.status_code == 200 - room.refresh_from_db() - assert room.configuration == {"can_publish_sources": ["camera"]} - - mock_update_metadata.assert_called_once_with( - room_name=str(room.id), - metadata={ - "access_level": room.access_level, - "configuration": {"can_publish_sources": ["camera"]}, - }, - ) - - -@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException) -def test_api_rooms_update_livekit_sync_failure(mock_update_metadata): +@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException]) +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception): """Should not fail the API request when the LiveKit metadata sync fails.""" + mock_update_metadata.side_effect = exception user = UserFactory() room = RoomFactory( users=[(user, random.choice(["administrator", "owner"]))], diff --git a/src/backend/core/tests/services/test_room_management.py b/src/backend/core/tests/services/test_room_management.py index a63e5aa4..50f4b392 100644 --- a/src/backend/core/tests/services/test_room_management.py +++ b/src/backend/core/tests/services/test_room_management.py @@ -5,6 +5,8 @@ from unittest import mock import pytest from livekit.api import TwirpError +from core.factories import RoomFactory +from core.models import RoomAccessLevel from core.services.room_management import ( RoomManagement, RoomManagementException, @@ -20,7 +22,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client): mock_api.aclose = mock.AsyncMock() mock_create_livekit_client.return_value = mock_api - RoomManagement().delete_room("room-abc") + RoomManagement.delete_room("room-abc") mock_api.room.delete_room.assert_awaited_once() request = mock_api.room.delete_room.await_args.args[0] @@ -39,7 +41,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client): mock_create_livekit_client.return_value = mock_api with pytest.raises(RoomNotFoundException): - RoomManagement().delete_room("missing-room") + RoomManagement.delete_room("missing-room") mock_api.aclose.assert_awaited_once() @@ -55,6 +57,25 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client): mock_create_livekit_client.return_value = mock_api with pytest.raises(RoomManagementException): - RoomManagement().delete_room("room-abc") + RoomManagement.delete_room("room-abc") mock_api.aclose.assert_awaited_once() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata): + """The room's configuration and access level are forwarded to LiveKit.""" + room = RoomFactory.build( + access_level=RoomAccessLevel.RESTRICTED, + configuration={"everyone_can_mute": True}, + ) + + RoomManagement.sync_room_metadata(room) + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={ + "configuration": {"everyone_can_mute": True}, + "access_level": RoomAccessLevel.RESTRICTED, + }, + ) diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index a4653c12..842f9f8e 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -16,8 +16,17 @@ import responses from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from rest_framework.test import APIClient +from core.analytics import AnalyticsEvent from core.factories import ApplicationFactory, RoomFactory, UserFactory -from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User +from core.models import ( + Application, + ApplicationScope, + RoleChoices, + Room, + RoomAccessLevel, + User, +) +from core.services.room_management import RoomManagement pytestmark = pytest.mark.django_db @@ -880,6 +889,509 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings): assert response.data["access_level"] == RoomAccessLevel.PUBLIC +@mock.patch("core.external_api.viewsets.analytics.capture") +def test_api_rooms_create_tracks_analytics(mock_capture): + """Creating a room should emit a ROOM_CREATED analytics event.""" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE]) + application = Application.objects.get() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.post( + "/external-api/v1.0/rooms/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 201 + + mock_capture.assert_called_once() + captured_user, event, properties = mock_capture.call_args[0] + + assert captured_user == user + assert event == AnalyticsEvent.ROOM_CREATED + assert properties == { + "room_id": response.data["id"], + "access_level": RoomAccessLevel.RESTRICTED, + "client_id": str(application.client_id), + "external_api": True, + "auth_method": "ApplicationJWTAuthentication", + "$set": {"email": user.email}, + } + + +def test_api_rooms_update_requires_authentication(): + """Updating a room without authentication should return 401.""" + + room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)]) + + client = APIClient() + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 401 + + +def test_api_rooms_update_requires_scope(): + """Updating a room requires the ROOMS_UPDATE scope.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + # Token without ROOMS_UPDATE scope + token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 403 + assert ( + "insufficient permissions. required scope: rooms:update" + in str(response.data).lower() + ) + + +def test_api_rooms_update_no_scope(): + """Updating a room without any scope should return 403.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, []) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 403 + assert "insufficient permissions." in str(response.data).lower() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_owner_success(mock_update_metadata, settings): + """An owner should be able to update the access level and the configuration.""" + + settings.APPLICATION_BASE_URL = "http://your-application.com" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + configuration={}, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + { + "access_level": RoomAccessLevel.RESTRICTED, + "configuration": {"everyone_can_mute": True}, + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["id"] == str(room.id) + assert response.data["access_level"] == RoomAccessLevel.RESTRICTED + assert response.data["configuration"] == {"everyone_can_mute": True} + assert response.data["url"] == f"http://your-application.com/{room.slug}" + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.RESTRICTED + assert room.configuration == {"everyone_can_mute": True} + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={ + "configuration": {"everyone_can_mute": True}, + "access_level": RoomAccessLevel.RESTRICTED, + }, + ) + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_replaces_configuration(mock_update_metadata): + """The configuration is replaced as a whole, it is not merged with the stored one.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True}, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"configuration": {"everyone_can_mute": False}}, + format="json", + ) + + assert response.status_code == 200 + + # The keys missing from the payload are dropped, not kept. + assert response.data["configuration"] == {"everyone_can_mute": False} + + room.refresh_from_db() + assert room.configuration == {"everyone_can_mute": False} + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={ + "configuration": {"everyone_can_mute": False}, + "access_level": room.access_level, + }, + ) + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_administrator_success(mock_update_metadata): + """An administrator should be able to update a room.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.ADMIN)], + access_level=RoomAccessLevel.TRUSTED, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 200 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.RESTRICTED + mock_update_metadata.assert_called_once() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_put_not_allowed(mock_update_metadata): + """PUT is not exposed: full replacement is not supported, only PATCH is.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + configuration={}, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.put( + f"/external-api/v1.0/rooms/{room.id}/", + { + "access_level": RoomAccessLevel.RESTRICTED, + "configuration": {"everyone_can_mute": True}, + }, + format="json", + ) + + assert response.status_code == 405 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.TRUSTED + assert room.configuration == {} + mock_update_metadata.assert_not_called() + + +@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None]) +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_without_privileges(mock_update_metadata, role): + """Members and users without any role should not be able to update a room.""" + + user = UserFactory() + users = [(user, role)] if role else [] + room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 403 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.TRUSTED + mock_update_metadata.assert_not_called() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_readonly_enforcement(mock_update_metadata): + """Read-only fields provided on update should be ignored, the slug stays immutable.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + expected_id, expected_name = str(room.id), room.name + expected_slug, expected_pin_code = room.slug, room.pin_code + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + { + "id": str(uuid.uuid4()), + "name": "fake-name", + "slug": "fake-slug", + "pin_code": "000000", + "access_level": RoomAccessLevel.RESTRICTED, + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["id"] == expected_id + assert response.data["name"] == expected_name + assert response.data["slug"] == expected_slug + + room.refresh_from_db() + assert str(room.id) == expected_id + assert room.name == expected_name + assert room.slug == expected_slug + assert room.pin_code == expected_pin_code + + # The one writable field in the payload was applied + assert room.access_level == RoomAccessLevel.RESTRICTED + mock_update_metadata.assert_called_once() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata): + """Updating a room with unsupported configuration keys should fail.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={}) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"configuration": {"unsupported_flag": True}}, + format="json", + ) + + assert response.status_code == 400 + assert "extra inputs are not permitted" in str(response.data).lower() + + room.refresh_from_db() + assert room.configuration == {} + mock_update_metadata.assert_not_called() + + +@pytest.mark.parametrize( + "invalid_configuration", + [ + {"can_publish_sources": ["invalid-source"]}, + {"everyone_can_mute": "invalid-value"}, + ], +) +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_rejects_invalid_configuration_values( + mock_update_metadata, invalid_configuration +): + """Updating a room with invalid configuration values should fail.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={}) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"configuration": invalid_configuration}, + format="json", + ) + + assert response.status_code == 400 + + room.refresh_from_db() + assert room.configuration == {} + mock_update_metadata.assert_not_called() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata): + """Switching a room to public should be disabled for the external API by default.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.PUBLIC}, + format="json", + ) + + assert response.status_code == 400 + assert "public rooms are disabled" in str(response.data).lower() + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.TRUSTED + mock_update_metadata.assert_not_called() + + +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_public_access_enabled_with_settings( + mock_update_metadata, settings +): + """Switching a room to public should be allowed when explicitly enabled.""" + + settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.PUBLIC}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["access_level"] == RoomAccessLevel.PUBLIC + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.PUBLIC + mock_update_metadata.assert_called_once() + + +@mock.patch("core.external_api.viewsets.analytics.capture") +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_unchanged_skips_livekit_sync( + mock_update_metadata, mock_capture +): + """An update that changes nothing should not sync metadata nor report changes.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + configuration={"everyone_can_mute": True}, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + { + "access_level": RoomAccessLevel.TRUSTED, + "configuration": {"everyone_can_mute": True}, + }, + format="json", + ) + + assert response.status_code == 200 + mock_update_metadata.assert_not_called() + + # The event is still emitted for auditing, but reports an empty delta. + _, _, properties = mock_capture.call_args[0] + assert properties["updated_fields"] == [] + + +@mock.patch("core.external_api.viewsets.analytics.capture") +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture): + """Updating a room should emit a ROOM_UPDATED analytics event.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + configuration={}, + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + application = Application.objects.get() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + { + "access_level": RoomAccessLevel.RESTRICTED, + "configuration": {"everyone_can_mute": True}, + }, + format="json", + ) + + assert response.status_code == 200 + + mock_capture.assert_called_once() + captured_user, event, properties = mock_capture.call_args[0] + + assert captured_user == user + assert event == AnalyticsEvent.ROOM_UPDATED + assert properties == { + "room_id": str(room.pk), + "access_level": RoomAccessLevel.RESTRICTED, + "updated_fields": ["access_level", "configuration"], + "previous_access_level": RoomAccessLevel.TRUSTED, + "client_id": str(application.client_id), + "external_api": True, + "auth_method": "ApplicationJWTAuthentication", + "$set": {"email": user.email}, + } + + mock_update_metadata.assert_called_once() + + def test_api_rooms_response_no_url(settings): """Response should not include url field when APPLICATION_BASE_URL is None.""" settings.APPLICATION_BASE_URL = None @@ -1497,6 +2009,106 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings): assert response.status_code == 403 +@responses.activate +@mock.patch.object(RoomManagement, "update_metadata") +def test_resource_server_updates_room_with_prefixed_scope( + mock_update_metadata, settings +): + """A resource server token carrying the prefixed update scope should be accepted.""" + + user = UserFactory(sub="very-specific-sub") + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + + settings.OIDC_RS_CLIENT_ID = "some_client_id" + settings.OIDC_RS_CLIENT_SECRET = "some_client_secret" + settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet" + + settings.OIDC_OP_URL = "https://oidc.example.com" + settings.OIDC_VERIFY_SSL = False + settings.OIDC_TIMEOUT = 5 + settings.OIDC_PROXY = None + settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks" + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + + responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID + "sub": "very-specific-sub", + "client_id": "some_service_provider", + "scope": "openid lasuite_meet lasuite_meet:rooms:update", + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_token") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 200 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.RESTRICTED + mock_update_metadata.assert_called_once() + + +@responses.activate +def test_resource_server_denies_room_update_without_update_scope(settings): + """A resource server token without the update scope should be denied.""" + + user = UserFactory(sub="very-specific-sub") + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + + settings.OIDC_RS_CLIENT_ID = "some_client_id" + settings.OIDC_RS_CLIENT_SECRET = "some_client_secret" + settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet" + + settings.OIDC_OP_URL = "https://oidc.example.com" + settings.OIDC_VERIFY_SSL = False + settings.OIDC_TIMEOUT = 5 + settings.OIDC_PROXY = None + settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks" + settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect" + + responses.add( + responses.POST, + "https://oidc.example.com/introspect", + json={ + "iss": "https://oidc.example.com", + "aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID + "sub": "very-specific-sub", + "client_id": "some_service_provider", + "scope": "openid lasuite_meet lasuite_meet:rooms:retrieve", + "active": True, + }, + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer some_token") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 403 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.TRUSTED + + # ============================== # Addons # ============================== @@ -1548,6 +2160,32 @@ def test_api_rooms_create_with_valid_addons_token(): assert room.get_role(user) == RoleChoices.OWNER +@mock.patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_with_valid_addons_token(mock_update_metadata): + """Updating a room with a valid addons token should succeed.""" + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], + access_level=RoomAccessLevel.TRUSTED, + ) + + token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.patch( + f"/external-api/v1.0/rooms/{room.id}/", + {"access_level": RoomAccessLevel.RESTRICTED}, + format="json", + ) + + assert response.status_code == 200 + + room.refresh_from_db() + assert room.access_level == RoomAccessLevel.RESTRICTED + mock_update_metadata.assert_called_once() + + def test_api_rooms_addons_token_inactive_user(): """Addons token for an inactive user should return 401.""" user = UserFactory(is_active=False)