(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:
lebaudantoine
2024-11-08 10:32:50 +01:00
committed by aleb_the_flash
parent f6f1222f47
commit b84628ee95
9 changed files with 572 additions and 0 deletions

View 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"

View 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