mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 14:17:59 +00:00
✨(backend) add connection-test API
Currently users have no way to reliably test their connection before joining a room. To address this, we plan to build a connection-test page. The testing requires a dedicated LiveKit token, issued without going through the room API, which is tied to registered meetings, lobby rules, and longer-lived access tokens. Introduce a new viewset for all diagnostics-related features. The first route issues a token for diagnostics, even for anonymous users. Each request creates a new dedicated room so users never share the same LiveKit room during tests. Tokens are short-lived (default 10 minutes) to limit reuse, and the endpoint is throttled to prevent abuse. A Celery worker also schedules a callback that deletes the room after a certain delay, in every case.
This commit is contained in:
committed by
aleb_the_flash
parent
06e73d7a5e
commit
b01a47bfd7
@@ -720,6 +720,7 @@ def test_receive_unsupported_event(mock_receive, service):
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
@@ -823,3 +824,33 @@ def test_receive_filter_processes_matching_events(
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_ignores_connection_test_room(
|
||||
mock_handle_room_started,
|
||||
mock_handle_room_finished,
|
||||
mock_receive,
|
||||
mock_livekit_config,
|
||||
settings,
|
||||
):
|
||||
"""Should ignore all webhook events for connection test rooms in receive()."""
|
||||
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_not_called()
|
||||
mock_handle_room_finished.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the RoomManagement service."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||
"""DeleteRoom is forwarded to the LiveKit API."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock()
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.room.delete_room.assert_awaited_once()
|
||||
request = mock_api.room.delete_room.await_args.args[0]
|
||||
assert request.room == "room-abc"
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||
"""Missing rooms raise RoomNotFoundException."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock(
|
||||
side_effect=TwirpError("not_found", "room not found", status=404)
|
||||
)
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomNotFoundException):
|
||||
RoomManagement().delete_room("missing-room")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
"""Unexpected Twirp errors raise RoomManagementException."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock(
|
||||
side_effect=TwirpError("internal", "boom", status=500)
|
||||
)
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for connection test Celery tasks."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
|
||||
"""RoomManagement.delete_room is called for connection-test rooms."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
delete_connection_test_room("connection-test-abc")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-abc")
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
|
||||
"""Refuse to delete rooms outside the connection-test namespace."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
delete_connection_test_room("production-room")
|
||||
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
|
||||
"""Missing rooms are treated as already cleaned up."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
|
||||
|
||||
delete_connection_test_room("connection-test-gone")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-gone")
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
|
||||
"""Unexpected LiveKit failures are swallowed after logging."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
|
||||
|
||||
delete_connection_test_room("connection-test-fail")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-fail")
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Test diagnostics API endpoints."""
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.api.throttling import (
|
||||
ConnectionTestAnonRateThrottle,
|
||||
ConnectionTestUserRateThrottle,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_url():
|
||||
"""The connection check is exposed under the diagnostics namespace."""
|
||||
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_rejects_get():
|
||||
"""Only POST is exposed, the endpoint has no side effect to trigger."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
|
||||
"""Each request gets a dedicated room and a short-lived token."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
response_a = client.post("/api/v1.0/diagnostics/connection/")
|
||||
response_b = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response_a.status_code == 200
|
||||
assert response_b.status_code == 200
|
||||
|
||||
data_a = response_a.json()
|
||||
data_b = response_b.json()
|
||||
|
||||
room_a = data_a["livekit"]["room"]
|
||||
room_b = data_b["livekit"]["room"]
|
||||
|
||||
assert room_a.startswith("connection-test-")
|
||||
assert room_b.startswith("connection-test-")
|
||||
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||
assert room_a != room_b
|
||||
assert data_a["livekit"]["url"]
|
||||
assert data_a["livekit"]["token"]
|
||||
assert data_a["livekit"]["expires_in"] == 600
|
||||
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
|
||||
"""Connection test tokens expire quickly for users."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
config = response.json()["livekit"]
|
||||
payload = jwt.decode(
|
||||
config["token"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
assert config["expires_in"] == 300
|
||||
assert payload["video"]["room"] == config["room"]
|
||||
assert payload["name"] == "Connection Test"
|
||||
assert payload["video"]["roomAdmin"] is False
|
||||
assert payload["exp"] - payload["nbf"] == 300
|
||||
|
||||
|
||||
@override_settings()
|
||||
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
|
||||
"""Logged-in users get a token bound to their own identity."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = jwt.decode(
|
||||
response.json()["livekit"]["token"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
assert payload["sub"] == str(user.sub)
|
||||
assert payload["video"]["roomAdmin"] is False
|
||||
assert payload["exp"] - payload["nbf"] == 300
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||
def test_api_diagnostics_connection_schedules_room_deletion(
|
||||
mock_apply_async, settings, client
|
||||
):
|
||||
"""When Celery is enabled, schedule a hard room delete after max age."""
|
||||
|
||||
settings.CELERY_ENABLED = True
|
||||
settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = 300
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
room = response.json()["livekit"]["room"]
|
||||
mock_apply_async.assert_called_once_with(args=[room], countdown=300)
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
|
||||
mock_apply_async, settings, client
|
||||
):
|
||||
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
|
||||
|
||||
settings.CELERY_ENABLED = False
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"throttle_class",
|
||||
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
|
||||
)
|
||||
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
|
||||
"""Both throttles stay wired to the action once routed through the viewset."""
|
||||
with (
|
||||
mock.patch.object(throttle_class, "allow_request", return_value=False),
|
||||
mock.patch.object(throttle_class, "wait", return_value=42),
|
||||
):
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_feature_flag(client, settings):
|
||||
"""Should return a not found error when the connection diagnostics feature is disabled."""
|
||||
|
||||
settings.CONNECTION_TEST_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user