✨(backend) add two new endpoints to start and stop a recording
The LiveKit egress worker interactions are proxied through the backend for security reasons. Allowing clients to directly use tokens with sufficient grants to start recordings could lead to misuse, enabling users to spam the egress worker API and potentially initiate a DDOS attack on the egress service. To prevent this, only users with room-specific privileges can initiate recordings. We make sure only one recording at the time can be made on a room. The requested recording mode is stored so it can be referenced later when the recording is saved, triggering a callback action as needed. A feature flag was also introduced for this capability; while this is a simple approach, a more robust system for managing feature flags could be valuable long-term. For now, KISS (Keep It Simple, Stupid) applies. The viewset endpoints were designed to be as straightforward as possible— let me know if anything can be improved.
This commit is contained in:
committed by
aleb_the_flash
parent
f6f1222f47
commit
b84628ee95
200
src/backend/core/tests/rooms/test_api_rooms_start_recording.py
Normal file
200
src/backend/core/tests/rooms/test_api_rooms_start_recording.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Mock worker service."""
|
||||
return mock.Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager(mock_worker_service):
|
||||
"""Mock worker service mediator."""
|
||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||
mock_mediator = mock.Mock()
|
||||
mock_mediator_class.return_value = mock_mediator
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start room recordings."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_non_owner_and_non_administrator():
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_recording_disabled(settings):
|
||||
"""Should fail if recording is disabled for the room."""
|
||||
settings.RECORDING_ENABLE = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_missing_mode(settings):
|
||||
"""Should fail if recording mode is not provided."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/start-recording/", {})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid request."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_worker_error(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should handle worker service errors appropriately."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Configure mock mediator to raise error
|
||||
mock_start = mock.Mock()
|
||||
mock_start.side_effect = RecordingStartError("Failed to connect to worker")
|
||||
|
||||
mock_worker_manager.start = mock_start
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
# Recording object should be created even if worker fails
|
||||
assert Recording.objects.count() == 1
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
def test_start_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should successfully start recording when everything is configured correctly."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_start = mock.Mock()
|
||||
mock_worker_manager.start = mock_start
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"message": f"Recording successfully started for room {room.slug}"
|
||||
}
|
||||
|
||||
# Verify the mediator was called with the recording
|
||||
recording = Recording.objects.first()
|
||||
mock_start.assert_called_once_with(recording)
|
||||
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
182
src/backend/core/tests/rooms/test_api_rooms_stop_recording.py
Normal file
182
src/backend/core/tests/rooms/test_api_rooms_stop_recording.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: stop recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Mock worker service."""
|
||||
return mock.Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager(mock_worker_service):
|
||||
"""Mock worker service mediator."""
|
||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||
mock_mediator = mock.Mock()
|
||||
mock_mediator_class.return_value = mock_mediator
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_stop_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to stop room recordings."""
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 401
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_non_owner_and_non_administrator():
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_recording_disabled(settings):
|
||||
"""Should fail if recording is disabled for the room."""
|
||||
|
||||
settings.RECORDING_ENABLE = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
# Verify no recording exists
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_stop_recording_no_active_recording(settings):
|
||||
"""Should fail when there is no active recording for the room."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No active recording found for this room."}
|
||||
|
||||
|
||||
def test_stop_recording_worker_error(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should handle worker service errors appropriately."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Configure mock mediator to raise error
|
||||
mock_stop = mock.Mock()
|
||||
mock_stop.side_effect = RecordingStopError("Failed to connect to worker")
|
||||
mock_worker_manager.stop = mock_stop
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to stop for room {room.slug}"
|
||||
}
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should successfully stop recording when everything is configured correctly."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_stop = mock.Mock()
|
||||
mock_worker_manager.stop = mock_stop
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
|
||||
|
||||
# Verify the recording still exists
|
||||
assert Recording.objects.count() == 1
|
||||
Reference in New Issue
Block a user