From 47a8acf673a8295506e91faf55ccee89d4608697 Mon Sep 17 00:00:00 2001 From: Arnaud Robin Date: Wed, 1 Jul 2026 17:47:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20connection-test=20AP?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently users have no way to reliably test their connection before joining a room. We then want to create a connection test page to adress this issue. The testing will require a dedicated LiveKit token without going through the room API, which is tied to registered meetings, lobby rules, and longer-lived access tokens. We introduce a new API endpoint GET /api/v1.0/connection-test/ to issue a dedicated token for diagnostics, even for anonymous users. Each request creates a new 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. --- src/backend/core/api/connection_test.py | 51 ++++++++++ src/backend/core/api/throttling.py | 12 +++ src/backend/core/services/livekit_events.py | 19 ++++ src/backend/core/tasks/__init__.py | 9 ++ src/backend/core/tasks/connection_test.py | 51 ++++++++++ .../tests/services/test_livekit_events.py | 28 ++++++ .../core/tests/tasks/test_connection_test.py | 51 ++++++++++ .../core/tests/test_api_connection_test.py | 94 +++++++++++++++++++ src/backend/core/urls.py | 6 ++ src/backend/core/utils.py | 5 + src/backend/meet/settings.py | 20 ++++ 11 files changed, 346 insertions(+) create mode 100644 src/backend/core/api/connection_test.py create mode 100644 src/backend/core/tasks/__init__.py create mode 100644 src/backend/core/tasks/connection_test.py create mode 100644 src/backend/core/tests/tasks/test_connection_test.py create mode 100644 src/backend/core/tests/test_api_connection_test.py diff --git a/src/backend/core/api/connection_test.py b/src/backend/core/api/connection_test.py new file mode 100644 index 00000000..aca16999 --- /dev/null +++ b/src/backend/core/api/connection_test.py @@ -0,0 +1,51 @@ +"""Connection test API endpoint.""" + +from datetime import timedelta +from uuid import uuid4 + +from django.conf import settings + +from rest_framework.decorators import api_view, throttle_classes +from rest_framework.response import Response + +from core.api.throttling import ( + ConnectionTestAnonRateThrottle, + ConnectionTestUserRateThrottle, +) +from core.tasks.connection_test import delete_connection_test_room +from core.utils import generate_token + +CONNECTION_TEST_USERNAME = "Test connexion" + + +@api_view(["GET"]) +@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle]) +def get_connection_test_config(request): + """Return a short-lived LiveKit token for an ephemeral connection test room.""" + 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 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, + }, + } + ) diff --git a/src/backend/core/api/throttling.py b/src/backend/core/api/throttling.py index b7b89b43..155d7059 100644 --- a/src/backend/core/api/throttling.py +++ b/src/backend/core/api/throttling.py @@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle): """Throttle Anonymous user requesting room generation callback""" scope = "creation_callback" + + +class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle): + """Throttle authenticated users requesting connection test tokens.""" + + scope = "connection_test" + + +class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle): + """Throttle anonymous users requesting connection test tokens.""" + + scope = "connection_test" diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index 8bdf1fd0..41077039 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -228,9 +228,21 @@ class LiveKitEventsService: # Silently ignoring EGRESS_ABORTED, EGRESS_FAILED + @staticmethod + def _is_connection_test_room(room_name: str) -> bool: + """Return True for ephemeral rooms created by the connection test endpoint.""" + return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX) + 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: @@ -256,6 +268,13 @@ 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: diff --git a/src/backend/core/tasks/__init__.py b/src/backend/core/tasks/__init__.py new file mode 100644 index 00000000..b433db5f --- /dev/null +++ b/src/backend/core/tasks/__init__.py @@ -0,0 +1,9 @@ +"""Celery tasks for the core app.""" + +from core.tasks.connection_test import delete_connection_test_room +from core.tasks.file import process_file_deletion + +__all__ = ( + "delete_connection_test_room", + "process_file_deletion", +) diff --git a/src/backend/core/tasks/connection_test.py b/src/backend/core/tasks/connection_test.py new file mode 100644 index 00000000..e6cb33ea --- /dev/null +++ b/src/backend/core/tasks/connection_test.py @@ -0,0 +1,51 @@ +"""Tasks related to connection test rooms.""" + +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.tasks._task import task +from core.utils import create_livekit_client + +logger = logging.getLogger(__name__) + + +@task +def delete_connection_test_room(room_name: str): + """Force-delete an ephemeral connection-test room. + + Used as a hard cap so a participant cannot keep an auto-refreshed + LiveKit session open indefinitely after requesting a test token. + """ + prefix = settings.CONNECTION_TEST_ROOM_PREFIX + if not room_name.startswith(prefix): + logger.error( + "Refusing to delete room '%s': expected prefix '%s'.", + room_name, + prefix, + ) + 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: + # 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() diff --git a/src/backend/core/tests/services/test_livekit_events.py b/src/backend/core/tests/services/test_livekit_events.py index 16adb661..a810ef51 100644 --- a/src/backend/core/tests/services/test_livekit_events.py +++ b/src/backend/core/tests/services/test_livekit_events.py @@ -6,6 +6,8 @@ Test LiveKitEvents service. import uuid from unittest import mock +from django.test.utils import override_settings + import pytest from livekit.api import EgressStatus @@ -551,6 +553,23 @@ 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(TelephonyService, "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() @@ -601,6 +620,15 @@ 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() diff --git a/src/backend/core/tests/tasks/test_connection_test.py b/src/backend/core/tests/tasks/test_connection_test.py new file mode 100644 index 00000000..81a71cea --- /dev/null +++ b/src/backend/core/tests/tasks/test_connection_test.py @@ -0,0 +1,51 @@ +"""Tests for connection test Celery tasks.""" + +from unittest import mock + +from django.test.utils import override_settings + +from livekit.api import TwirpError + +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 + + 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() + + +@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): + """Refuse to delete rooms outside the connection-test namespace.""" + delete_connection_test_room("production-room") + + mock_create_livekit_client.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): + """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 + + delete_connection_test_room("connection-test-gone") + + mock_api.aclose.assert_awaited_once() diff --git a/src/backend/core/tests/test_api_connection_test.py b/src/backend/core/tests/test_api_connection_test.py new file mode 100644 index 00000000..a67e71dc --- /dev/null +++ b/src/backend/core/tests/test_api_connection_test.py @@ -0,0 +1,94 @@ +"""Test connection test API endpoint.""" + +import uuid +from unittest import mock + +from django.test.utils import override_settings + +import jwt +import pytest +from rest_framework.test import APIClient + +from core.api.connection_test import CONNECTION_TEST_USERNAME + +pytestmark = pytest.mark.django_db + + +@override_settings( + CONNECTION_TEST_TOKEN_TTL_SECONDS=600, + CONNECTION_TEST_ROOM_PREFIX="connection-test-", +) +def test_api_connection_test_returns_ephemeral_livekit_config(): + """Each request gets a dedicated room and a short-lived token.""" + client = APIClient() + response_a = client.get("/api/v1.0/connection-test/") + response_b = client.get("/api/v1.0/connection-test/") + + 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"] + + +@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300) +def test_api_connection_test_token_is_short_lived_for_user(settings): + """Connection test tokens expire quickly for users.""" + client = APIClient() + response = client.get("/api/v1.0/connection-test/") + + 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_USERNAME + assert payload["video"]["roomAdmin"] is False + assert payload["exp"] - payload["nbf"] == 300 + + +@override_settings( + CELERY_ENABLED=True, + CONNECTION_TEST_ROOM_MAX_AGE_SECONDS=300, + CONNECTION_TEST_ROOM_PREFIX="connection-test-", +) +@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async") +def test_api_connection_test_schedules_room_deletion(mock_apply_async): + """When Celery is enabled, schedule a hard room delete after max age.""" + client = APIClient() + response = client.get("/api/v1.0/connection-test/") + + assert response.status_code == 200 + room = response.json()["livekit"]["room"] + mock_apply_async.assert_called_once_with(args=[room], countdown=300) + + +@override_settings(CELERY_ENABLED=False) +@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async") +def test_api_connection_test_skips_room_deletion_without_celery(mock_apply_async): + """Without Celery, do not schedule deletion (apply_async would run immediately).""" + client = APIClient() + response = client.get("/api/v1.0/connection-test/") + + assert response.status_code == 200 + mock_apply_async.assert_not_called() diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 3bc4f5e8..769b885b 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -8,6 +8,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter from core.addons import viewsets as addons_viewsets from core.api import get_frontend_configuration, viewsets +from core.api.connection_test import get_connection_test_config from core.external_api import viewsets as external_viewsets # - Main endpoints @@ -46,6 +47,11 @@ urlpatterns = [ *router.urls, *oidc_urls, path("config/", get_frontend_configuration, name="config"), + path( + "connection-test/", + get_connection_test_config, + name="connection_test", + ), ] ), ), diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index b35b5bcb..f37f0d3e 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -12,6 +12,7 @@ import mimetypes import random import secrets import string +from datetime import timedelta from functools import lru_cache from typing import List, Optional from uuid import uuid4 @@ -67,6 +68,7 @@ def generate_token( sources: Optional[List[str]] = None, role: Optional[str] = None, participant_id: Optional[str] = None, + ttl: Optional[timedelta] = None, ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -82,6 +84,7 @@ def generate_token( role (Optional[str]): Room's access role if any participant_id (Optional[str]): Stable identifier for anonymous users; used as identity when user.is_anonymous. + ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default. Returns: str: The LiveKit JWT access token. @@ -135,6 +138,8 @@ def generate_token( } ) ) + if ttl is not None: + token = token.with_ttl(ttl) return token.to_jwt() diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f392917d..b2fee19d 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -349,6 +349,11 @@ class Base(Configuration): environ_name="CREATION_CALLBACK_THROTTLE_RATES", environ_prefix=None, ), + "connection_test": values.Value( + default="30/minute", + environ_name="CONNECTION_TEST_THROTTLE_RATES", + environ_prefix=None, + ), }, } MONITORED_THROTTLE_FAILURE_CALLBACK = ( @@ -655,6 +660,21 @@ class Base(Configuration): environ_prefix=None, default=False, ) + CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue( + 300, + environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS", + environ_prefix=None, + ) + CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = values.PositiveIntegerValue( + 300, + environ_name="CONNECTION_TEST_ROOM_MAX_AGE_SECONDS", + environ_prefix=None, + ) + CONNECTION_TEST_ROOM_PREFIX = values.Value( + "connection-test-", + environ_name="CONNECTION_TEST_ROOM_PREFIX", + environ_prefix=None, + ) LIVEKIT_VERIFY_SSL = values.BooleanValue( True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None )