fixup! (backend) add connection-test API

This commit is contained in:
lebaudantoine
2026-08-04 23:23:26 +02:00
parent ff5423e1d5
commit 4f0c3567b5
10 changed files with 248 additions and 188 deletions
-77
View File
@@ -1,77 +0,0 @@
"""Diagnostics API endpoints."""
from datetime import timedelta
from uuid import uuid4
from django.conf import settings
from rest_framework import decorators, permissions, viewsets
from rest_framework import (
response as drf_response,
)
from core.api import throttling
from core.tasks.connection_test import delete_connection_test_room
from core.utils import generate_token
CONNECTION_TEST_USERNAME = "Test connexion"
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [permissions.AllowAny]
@decorators.action(
detail=False,
methods=["get"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
delete_connection_test_room.apply_async(
args=[room],
countdown=settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username=CONNECTION_TEST_USERNAME,
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
+68
View File
@@ -2,8 +2,10 @@
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from urllib.parse import unquote, urlparse
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -27,6 +29,9 @@ from rest_framework import (
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
@@ -36,6 +41,7 @@ from rest_framework import (
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -93,7 +99,9 @@ from core.services.room_roles import (
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
@@ -1563,3 +1571,63 @@ class FileViewSet(
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [drf_permissions.AllowAny]
@decorators.action(
detail=False,
methods=["POST"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
delete_connection_test_room.apply_async(
args=[room],
countdown=settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username="Connection Test",
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
+7 -14
View File
@@ -137,6 +137,13 @@ class LiveKitEventsService:
room_name = data.room.name or data.egress_info.room_name
if self._is_connection_test_room(room_name):
logger.info(
"Ignoring webhook event for connection test room '%s'.",
room_name,
)
return
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
@@ -236,13 +243,6 @@ class LiveKitEventsService:
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_started event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
@@ -268,13 +268,6 @@ class LiveKitEventsService:
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_finished event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
@@ -8,6 +8,7 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
@@ -88,3 +89,30 @@ class RoomManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the deletion otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted LiveKit room %s", room_name)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping deletion",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception("Unexpected error deleting room %s", room_name)
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
+9 -21
View File
@@ -4,14 +4,12 @@ import logging
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=no-name-in-module
DeleteRoomRequest,
TwirpError,
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.tasks._task import task
from core.utils import create_livekit_client
logger = logging.getLogger(__name__)
@@ -32,20 +30,10 @@ def delete_connection_test_room(room_name: str):
)
return
async_to_sync(_delete_room)(room_name)
async def _delete_room(room_name: str):
lkapi = create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted connection test room '%s'.", room_name)
except TwirpError as exc:
RoomManagement().delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info(
"Could not delete connection test room '%s': %s",
room_name,
exc,
)
finally:
await lkapi.aclose()
logger.info("Connection test room '%s' already gone.", room_name)
except RoomManagementException:
logger.exception("Failed to delete connection test room '%s'.", room_name)
@@ -6,8 +6,6 @@ Test LiveKitEvents service.
import uuid
from unittest import mock
from django.test.utils import override_settings
import pytest
from livekit.api import EgressStatus
@@ -577,23 +575,6 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_clear_cache.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test")
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_ignores_connection_test_room(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should ignore room_finished events for connection test rooms."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
@@ -697,15 +678,6 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
service._handle_room_started(mock_data)
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
def test_handle_room_started_ignores_connection_test_room(service, settings):
"""Should ignore room_started events for connection test rooms."""
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
@@ -748,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
@@ -851,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()
@@ -4,48 +4,48 @@ from unittest import mock
from django.test.utils import override_settings
from livekit.api import TwirpError
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.tasks.connection_test import delete_connection_test_room
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_calls_livekit(mock_create_livekit_client):
"""DeleteRoom is called for rooms with the connection-test prefix."""
mock_api = mock.MagicMock()
mock_api.room.delete_room = mock.AsyncMock()
mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api
@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_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0]
assert request.room == "connection-test-abc"
mock_api.aclose.assert_awaited_once()
mock_delete_room.assert_called_once_with("connection-test-abc")
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_refuses_other_rooms(mock_create_livekit_client):
@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_create_livekit_client.assert_not_called()
mock_delete_room.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test")
@mock.patch("core.tasks.connection_test.create_livekit_client")
def test_delete_connection_test_room_ignores_missing_room(mock_create_livekit_client):
@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."""
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
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_api.aclose.assert_awaited_once()
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")
+14 -17
View File
@@ -10,7 +10,6 @@ import jwt
import pytest
from rest_framework.test import APIClient
from core.api.diagnostics import CONNECTION_TEST_USERNAME
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
@@ -19,18 +18,16 @@ from core.factories import UserFactory
pytestmark = pytest.mark.django_db
CONNECTION_URL = "/api/v1.0/diagnostics/connection/"
def test_api_diagnostics_connection_url():
"""The connection check is exposed under the diagnostics namespace."""
assert reverse("diagnostics-connection") == CONNECTION_URL
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
def test_api_diagnostics_connection_rejects_post():
"""Only GET is exposed, the endpoint has no side effect to trigger."""
def test_api_diagnostics_connection_rejects_get():
"""Only POST is exposed, the endpoint has no side effect to trigger."""
client = APIClient()
response = client.post(CONNECTION_URL)
response = client.get("/api/v1.0/diagnostics/connection/")
assert response.status_code == 405
@@ -41,8 +38,8 @@ def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, c
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response_a = client.get(CONNECTION_URL)
response_b = client.get(CONNECTION_URL)
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
@@ -70,7 +67,7 @@ def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, clie
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
client = APIClient()
response = client.get(CONNECTION_URL)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
@@ -84,7 +81,7 @@ def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, clie
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == CONNECTION_TEST_USERNAME
assert payload["name"] == "Connection Test"
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
@@ -98,7 +95,7 @@ def test_api_diagnostics_connection_token_for_authenticated_user(settings, clien
user = UserFactory()
client.force_login(user)
response = client.get(CONNECTION_URL)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
@@ -114,7 +111,7 @@ def test_api_diagnostics_connection_token_for_authenticated_user(settings, clien
assert payload["exp"] - payload["nbf"] == 300
@mock.patch("core.api.diagnostics.delete_connection_test_room.apply_async")
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
def test_api_diagnostics_connection_schedules_room_deletion(
mock_apply_async, settings, client
):
@@ -124,21 +121,21 @@ def test_api_diagnostics_connection_schedules_room_deletion(
settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = 300
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
response = client.get(CONNECTION_URL)
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.diagnostics.delete_connection_test_room.apply_async")
@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.get(CONNECTION_URL)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
@@ -154,6 +151,6 @@ def test_api_diagnostics_connection_is_throttled(throttle_class, client):
mock.patch.object(throttle_class, "allow_request", return_value=False),
mock.patch.object(throttle_class, "wait", return_value=42),
):
response = client.get(CONNECTION_URL)
response = client.post("/api/v1.0/diagnostics/connection/")
assert response.status_code == 429
+2 -2
View File
@@ -7,7 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter, SimpleRouter
from core.addons import viewsets as addons_viewsets
from core.api import diagnostics, get_frontend_configuration, viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
from core.roomkit import viewsets as roomkit_viewsets
@@ -32,7 +32,7 @@ router.register(
)
router.register(
"diagnostics",
diagnostics.DiagnosticsViewSet,
viewsets.DiagnosticsViewSet,
basename="diagnostics",
)