diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fa2cd3..59ffdd51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(backend) add metadata collection of VAD, connection and chat events +- 🔒️(backend) add validation of Room.configuration ## [1.14.0] - 2026-04-16 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0d03ace5..77a9b278 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _ from django_pydantic_field.rest_framework import SchemaField from pydantic import BaseModel, Field +from pydantic import ValidationError as PydanticValidationError from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from timezone_field.rest_framework import TimeZoneSerializerField @@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer): fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"] read_only_fields = ["id", "slug", "pin_code"] + def validate_configuration(self, value): + """Validate room configuration against the RoomConfiguration schema.""" + if value is None or value == {}: + return value + try: + RoomConfiguration.model_validate(value) + except PydanticValidationError as e: + raise serializers.ValidationError(e.errors()) from e + return value + def to_representation(self, instance): """ Add users only for administrator users. @@ -306,6 +317,22 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer): ) +RoomConfigurationTrackSource = Literal[ + "camera", "microphone", "screen_share", "screen_share_audio" +] + + +class RoomConfiguration(BaseModel): + """Validate room configuration structure. + + Unknown fields are rejected. + """ + + can_publish_sources: list[RoomConfigurationTrackSource] | None = None + + model_config = {"extra": "forbid"} + + TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"] diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 16fa2a3d..90ee4512 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -123,7 +123,7 @@ class LobbyService: def request_entry( self, - room, + room: models.Room, request, username: str, ) -> Tuple[LobbyParticipant, Optional[Dict]]: diff --git a/src/backend/core/tests/files/test_api_files_create.py b/src/backend/core/tests/files/test_api_files_create.py index 5cb055cb..942315ae 100644 --- a/src/backend/core/tests/files/test_api_files_create.py +++ b/src/backend/core/tests/files/test_api_files_create.py @@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success(): policy_parsed = urlparse(policy) assert policy_parsed.scheme == "http" - assert policy_parsed.netloc == "localhost:9000" + assert policy_parsed.netloc in ["minio:9000", "localhost:9000"] assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png" query_params = parse_qs(policy_parsed.query) diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py index f317ddd8..ebc9e7d5 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token): """ room = RoomFactory( access_level=RoomAccessLevel.PUBLIC, - configuration={"can_publish_sources": ["mock-source"]}, + configuration={"can_publish_sources": ["camera"]}, ) user = UserFactory() @@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token): user=user, username=None, color=None, - sources=["mock-source"], + sources=["camera"], is_admin_or_owner=False, participant_id=None, ) @@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti other_user = UserFactory() room = RoomFactory( - configuration={"can_publish_sources": ["mock-source"]}, + configuration={"can_publish_sources": ["camera"]}, ) UserResourceAccessFactory(resource=room, user=user, role="member") UserResourceAccessFactory(resource=room, user=other_user, role="member") @@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti user=user, username=None, color=None, - sources=["mock-source"], + sources=["camera"], is_admin_or_owner=False, participant_id=None, ) 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 da3020a1..258be673 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_update.py +++ b/src/backend/core/tests/rooms/test_api_rooms_update.py @@ -67,7 +67,7 @@ def test_api_rooms_update_members(): "name": "New name", "slug": "should-be-ignored", "access_level": RoomAccessLevel.RESTRICTED, - "configuration": {"the_key": "the_value"}, + "configuration": {"can_publish_sources": ["camera", "microphone"]}, }, format="json", ) @@ -95,7 +95,7 @@ def test_api_rooms_update_administrators(): "name": "New name", "slug": "should-be-ignored", "access_level": RoomAccessLevel.PUBLIC, - "configuration": {"the_key": "the_value"}, + "configuration": {"can_publish_sources": ["camera", "microphone"]}, }, format="json", ) @@ -104,7 +104,98 @@ def test_api_rooms_update_administrators(): assert room.name == "New name" assert room.slug == "new-name" assert room.access_level == RoomAccessLevel.PUBLIC - assert room.configuration == {"the_key": "the_value"} + assert room.configuration == {"can_publish_sources": ["camera", "microphone"]} + + +@pytest.mark.parametrize( + "configuration", + [ + {}, + {"can_publish_sources": ["camera", "microphone"]}, + { + "can_publish_sources": [ + "camera", + "microphone", + "screen_share", + "screen_share_audio", + ] + }, + {"can_publish_sources": []}, + {"can_publish_sources": None}, + ], +) +def test_api_rooms_update_configuration_valid(configuration): + """Administrators should be allowed to set valid configurations.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"configuration": configuration}, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.configuration == configuration + + +def test_api_rooms_update_configuration_extra_keys_rejected(): + """Extra keys in configuration should be rejected.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + { + "configuration": { + "can_publish_sources": ["camera"], + "arbitrary_key": "value", + } + }, + format="json", + ) + assert response.status_code == 400 + room.refresh_from_db() + assert room.configuration == {} + + +@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"]) +def test_api_rooms_update_configuration_invalid_source_value(invalid_source): + """Invalid source values should be rejected.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"configuration": {"can_publish_sources": [invalid_source]}}, + format="json", + ) + assert response.status_code == 400 + room.refresh_from_db() + assert room.configuration == {} + + +def test_api_rooms_update_configuration_wrong_type(): + """Configuration values with wrong types should be rejected.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")]) + 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 == 400 + room.refresh_from_db() + assert room.configuration == {} def test_api_rooms_update_administrators_of_another():