diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a086cd7..b8bee658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(backend) update a room's access level and configuration from the external API + ### Changed - 📱(frontend) collapse mobile control bar items on narrow viewports 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 bbaa1da4..762d3f9c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -89,11 +89,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 sync_room_metadata from core.services.room_roles import ( RoomRoleError, RoomRoleService, @@ -370,26 +366,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, - ) + 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..0cd0cce0 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -25,6 +25,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 sync_room_metadata from ..services.provisional_user_service import ( ProvisionalUserCreationDisabledError, @@ -142,6 +143,7 @@ class RoomViewSet( mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, + mixins.UpdateModelMixin, viewsets.GenericViewSet, ): """Application-delegated API for room management. @@ -154,8 +156,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,6 +195,38 @@ class RoomViewSet( serializer = self.get_serializer(queryset, many=True) return drf_response.Response(serializer.data) + 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): """Set the current user as owner of the newly created room.""" room = serializer.save() @@ -198,27 +236,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): + """Persist the room update, sync it to LiveKit, then log and track it.""" + + previous_values = { + "access_level": serializer.instance.access_level, + "configuration": 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: + 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..3f513ac5 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -48,7 +48,7 @@ class ResourceFactory(factory.django.DjangoModelFactory): else: UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) - self.save() + self.save() class UserResourceAccessFactory(factory.django.DjangoModelFactory): diff --git a/src/backend/core/services/room_management.py b/src/backend/core/services/room_management.py index 163af6e2..7ee19bda 100644 --- a/src/backend/core/services/room_management.py +++ b/src/backend/core/services/room_management.py @@ -116,3 +116,32 @@ class RoomManagement: raise RoomManagementException("Could not delete room") from e finally: await lkapi.aclose() + + +def sync_room_metadata(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: + 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, + ) 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..ab538190 100644 --- a/src/backend/core/tests/services/test_room_management.py +++ b/src/backend/core/tests/services/test_room_management.py @@ -5,10 +5,13 @@ 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, RoomNotFoundException, + sync_room_metadata, ) @@ -58,3 +61,22 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client): 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}, + ) + + 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)