diff --git a/src/backend/core/tests/rooms/test_api_rooms_delete.py b/src/backend/core/tests/rooms/test_api_rooms_delete.py index baab7fce..5f22ba5f 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_delete.py +++ b/src/backend/core/tests/rooms/test_api_rooms_delete.py @@ -86,10 +86,11 @@ def test_api_rooms_delete_administrators(): assert Room.objects.count() == 1 -def test_api_rooms_delete_owners(): +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_owners(mock_delete_room): """ Authenticated users should be able to delete a room for which they are directly - owner. + owner. The room is soft deleted and its LiveKit room is closed. """ user = UserFactory() room = RoomFactory(users=[(user, "owner")]) @@ -102,7 +103,9 @@ def test_api_rooms_delete_owners(): ) assert response.status_code == 204 + mock_delete_room.assert_called_once_with(str(room.id)) assert Room.objects.exists() is False + assert Room.all_objects.get(id=room.id).deleted_at is not None @mock.patch.object( diff --git a/src/backend/core/tests/test_external_api_rooms.py b/src/backend/core/tests/test_external_api_rooms.py index f67a1725..ecb460f5 100644 --- a/src/backend/core/tests/test_external_api_rooms.py +++ b/src/backend/core/tests/test_external_api_rooms.py @@ -26,7 +26,11 @@ from core.models import ( RoomAccessLevel, User, ) -from core.services.room_management import RoomManagement, RoomNotFoundException +from core.services.room_management import ( + RoomManagement, + RoomManagementException, + RoomNotFoundException, +) pytestmark = pytest.mark.django_db @@ -1439,6 +1443,144 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture): mock_update_metadata.assert_called_once() +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_requires_authentication(mock_delete_room): + """Deleting a room without authentication should return 401.""" + + room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)]) + + client = APIClient() + response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 401 + assert Room.objects.filter(id=room.id).exists() is True + mock_delete_room.assert_not_called() + + +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_requires_scope(mock_delete_room): + """Deleting a room requires the ROOMS_DELETE scope.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + # Token without ROOMS_DELETE scope + token = generate_test_token( + user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE] + ) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 403 + assert ( + "insufficient permissions. required scope: rooms:delete" + in str(response.data).lower() + ) + assert Room.objects.filter(id=room.id).exists() is True + mock_delete_room.assert_not_called() + + +@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None]) +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_without_ownership(mock_delete_room, role): + """Only owners should be able to delete a room, administrators included.""" + + user = UserFactory() + users = [(user, role)] if role else [] + room = RoomFactory(users=users) + + token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 403 + assert Room.objects.filter(id=room.id).exists() is True + mock_delete_room.assert_not_called() + + +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_unknown_room(mock_delete_room): + """Deleting a room that does not exist should return 404.""" + + user = UserFactory() + token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/") + + assert response.status_code == 404 + mock_delete_room.assert_not_called() + + +@mock.patch("core.external_api.viewsets.analytics.capture") +@mock.patch.object(RoomManagement, "delete_room") +def test_api_rooms_delete_success(mock_delete_room, mock_capture): + """Owners should be able to delete a room: it is soft deleted, its LiveKit + room is closed and a ROOM_DELETED analytics event is emitted.""" + + user = UserFactory() + room = RoomFactory( + users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED + ) + + token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE]) + application = Application.objects.get() + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 204 + mock_delete_room.assert_called_once_with(str(room.id)) + assert Room.objects.filter(id=room.id).exists() is False + assert Room.all_objects.get(id=room.id).deleted_at is not None + + mock_capture.assert_called_once() + captured_user, event, properties = mock_capture.call_args[0] + + assert captured_user == user + assert event == AnalyticsEvent.ROOM_DELETED + assert properties == { + "room_id": str(room.pk), + "access_level": RoomAccessLevel.TRUSTED, + "client_id": str(application.client_id), + "external_api": True, + "auth_method": "ApplicationJWTAuthentication", + "$set": {"email": user.email}, + } + + +@mock.patch("core.external_api.viewsets.analytics.capture") +@mock.patch.object( + RoomManagement, + "delete_room", + side_effect=RoomManagementException("Could not delete room"), +) +def test_api_rooms_delete_livekit_failure(mock_delete_room, mock_capture): + """When the LiveKit room can't be closed, the deletion should be rolled back + and no analytics event emitted.""" + + user = UserFactory() + room = RoomFactory(users=[(user, RoleChoices.OWNER)]) + + token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE]) + + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = client.delete(f"/external-api/v1.0/rooms/{room.id}/") + + assert response.status_code == 500 + assert response.json() == {"detail": "Could not delete the room, please try again."} + mock_delete_room.assert_called_once_with(str(room.id)) + assert Room.objects.get(id=room.id).deleted_at is None + mock_capture.assert_not_called() + + @mock.patch.object( RoomManagement, "delete_room", diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 474ab48a..fe7ca6b9 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -294,6 +294,7 @@ export const Conference = ({ return case DisconnectReason.DUPLICATE_IDENTITY: case DisconnectReason.PARTICIPANT_REMOVED: + case DisconnectReason.ROOM_DELETED: navigateTo( 'feedback', {}, diff --git a/src/frontend/src/features/rooms/routes/Feedback.tsx b/src/frontend/src/features/rooms/routes/Feedback.tsx index 7e93ed69..1107694a 100644 --- a/src/frontend/src/features/rooms/routes/Feedback.tsx +++ b/src/frontend/src/features/rooms/routes/Feedback.tsx @@ -31,6 +31,7 @@ const buttonClass = css({ enum DisconnectReasonKey { DuplicateIdentity = 'duplicateIdentity', ParticipantRemoved = 'participantRemoved', + RoomDeleted = 'roomDeleted', } const FeedbackRoute = () => { @@ -46,6 +47,8 @@ const FeedbackRoute = () => { return DisconnectReasonKey.DuplicateIdentity case DisconnectReason.PARTICIPANT_REMOVED: return DisconnectReasonKey.ParticipantRemoved + case DisconnectReason.ROOM_DELETED: + return DisconnectReasonKey.RoomDeleted } }, []) @@ -56,7 +59,10 @@ const FeedbackRoute = () => { } }, []) - const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved + // Rejoining is not possible once removed or once the room is deleted + const showBackButton = + reasonKey !== DisconnectReasonKey.ParticipantRemoved && + reasonKey !== DisconnectReasonKey.RoomDeleted return ( diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index cae27cae..098a46ce 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -3,7 +3,8 @@ "heading": { "normal": "Du hast das Meeting verlassen", "duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten", - "participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt" + "participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt", + "roomDeleted": "Dieses Meeting wurde gelöscht" }, "home": "Zur Startseite zurückkehren", "back": "Dem Meeting erneut beitreten" diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index edbe85b9..8146564b 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -3,7 +3,8 @@ "heading": { "normal": "You have left the meeting", "duplicateIdentity": "You have joined the meeting from another device", - "participantRemoved": "You have been removed from the meeting by a host" + "participantRemoved": "You have been removed from the meeting by a host", + "roomDeleted": "This meeting has been deleted" }, "home": "Return to home", "back": "Rejoin the meeting" diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json index 9bdc0fda..ffe6f1e4 100644 --- a/src/frontend/src/locales/es/rooms.json +++ b/src/frontend/src/locales/es/rooms.json @@ -3,7 +3,8 @@ "heading": { "normal": "Has salido de la reunión", "duplicateIdentity": "Te has unido a la reunión desde otro dispositivo", - "participantRemoved": "Un administrador te ha expulsado de la llamada" + "participantRemoved": "Un administrador te ha expulsado de la llamada", + "roomDeleted": "Esta reunión ha sido eliminada" }, "home": "Volver al inicio", "back": "Volver a la reunión" diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index c08155bd..a12d6175 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -3,7 +3,8 @@ "heading": { "normal": "Vous avez quitté la réunion", "duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil", - "participantRemoved": "Vous avez été exclu de l'appel par un administrateur" + "participantRemoved": "Vous avez été exclu de l'appel par un administrateur", + "roomDeleted": "Cette réunion a été supprimée" }, "home": "Retourner à l'accueil", "back": "Réintégrer la réunion" diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index bb0bc8b1..670b1e06 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -3,7 +3,8 @@ "heading": { "normal": "Je hebt de vergadering verlaten", "duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend", - "participantRemoved": "U bent door een beheerder uit het gesprek verwijderd" + "participantRemoved": "U bent door een beheerder uit het gesprek verwijderd", + "roomDeleted": "Deze vergadering is verwijderd" }, "home": "Keer terug naar het hoofdscherm", "back": "Sluit weer bij de vergadering aan"