Compare commits

..

2 Commits

Author SHA1 Message Date
Arnaud Robin 56c381cbee (frontend) add connection test feature
Introduce a new connection test page to allow users to verify
their device and network compatibility with the application.
The feature also supports generating and downloading a detailed report
of the test results.
2026-07-30 16:18:56 +02:00
Arnaud Robin 0b53a26406 (backend) add connection-test API
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.
2026-07-30 16:18:56 +02:00
44 changed files with 1099 additions and 1021 deletions
+1 -2
View File
@@ -14,7 +14,7 @@ and this project adheres to
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
- ✨(backend) add roomkit viewset to start a room without WebRTC join
- ✨(frontend) add connection test feature
### Changed
@@ -27,7 +27,6 @@ and this project adheres to
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
- 🚚(backend) rename TelephonyService to SIPManagement
## Fixed
-6
View File
@@ -389,12 +389,6 @@ build-k8s-cluster: \
./bin/start-kind.sh
.PHONY: build-k8s-cluster
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
build-k8s-cluster-orbstack: \
env.d/development/kube-secret
./bin/start-orbstack.sh
.PHONY: build-k8s-cluster-orbstack
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
-6
View File
@@ -1,11 +1,5 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
# a no-op for kind and a safety net for older Tilt versions.
allow_k8s_contexts('orbstack')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env bash
#
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
# cluster (macOS) instead of kind.
#
# This replicates what bin/start-kind.sh (numerique-gouv/tools
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
# - no kind cluster: OrbStack ships a lightweight single-node cluster
# - no local registry (kind-registry): OrbStack's cluster shares the
# Docker image store, so images built by Tilt are directly visible
# to pods. Tilt detects the "orbstack" context as a local cluster
# and skips pushing images entirely.
#
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
set -o errexit
APPLICATION=${1:-meet}
CONTEXT="orbstack"
echo "0. Check OrbStack Kubernetes is available"
if ! command -v mkcert >/dev/null 2>&1; then
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
exit 1
fi
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
if command -v orb >/dev/null 2>&1; then
orb start k8s
else
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
exit 1
fi
fi
kubectl config use-context "${CONTEXT}"
echo "0b. Check ports 80/443 are free on localhost"
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
# cluster is still running, its docker proxy already holds 80/443.
# Skip the check if ingress-nginx is already installed here: in that case
# the listener on 80/443 is our own LoadBalancer.
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
for port in 80 443; do
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "❌ Port ${port} is already in use on the host."
echo " If the kind cluster is running, delete it first:"
echo " kind delete cluster --name suite"
exit 1
fi
done
fi
echo "1. Create ca"
CURRENT_DIR=$(pwd)
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd "${CURRENT_DIR}"
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
# guarded individually so the script is safe to re-run after a partial
# failure (unlike the upstream kind script, which guards the whole block
# on namespace existence).
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
# (there is no registry on OrbStack).
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
fi
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
# The meet charts render Ingresses without ingressClassName. The kind
# provider manifest handles this via --watch-ingress-without-class=true;
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
]'
fi
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
]'
fi
cat <<EOF | kubectl apply -n ingress-nginx -f -
apiVersion: v1
data:
allow-snippet-annotations: "true"
annotations-risk-level: Critical
custom-http-errors: 500,501,502,503,504
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
EOF
echo "2b. Wait for the ingress controller to be ready"
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
# Rewrite these names to the ingress-nginx service, like the kind setup does.
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
>/tmp/Corefile.orbstack
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
kubectl -n kube-system rollout restart deployments/coredns
fi
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
echo "4. Setup namespace"
kubectl create ns "${APPLICATION}"
fi
kubectl config set-context --current --namespace="${APPLICATION}"
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
echo "5. Inject our custom CA in a configmap for certifi"
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
fi
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
# Before Tilt deploys the app this returns the styled 404 from the default
# backend — that still proves LB -> controller works. 000 means the
# LoadBalancer is not bound to localhost.
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
if [ "${HTTP_CODE}" = "000" ]; then
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
else
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
fi
echo "6. Check pod readiness across all namespaces..."
sleep_interval=10
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
sleep $((sleep_interval * 2))
check_pods_ready() {
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
local attempt=1
while [ $attempt -le $max_attempts ]; do
echo "Attempt $attempt/$max_attempts - Checking pod status..."
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
if [ "$not_ready_count" -eq 0 ]; then
echo "✅ All pods are ready!"
return 0
else
echo "$not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
sleep $sleep_interval
((attempt++))
fi
done
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
echo "Final pod status:"
kubectl get po -A
return 1
}
if check_pods_ready; then
echo "🎉 Cluster is fully ready!"
else
echo "⚠️ Some pods may need manual intervention"
exit 1
fi
-21
View File
@@ -143,24 +143,3 @@ $ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
### Alternative: OrbStack's built-in Kubernetes (macOS)
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
```shellscript
$ make build-k8s-cluster-orbstack
```
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
```shellscript
$ make start-tilt-keycloak
```
Notes:
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
-3
View File
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+51
View File
@@ -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 = "Connection Test"
@api_view(["POST"])
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
def create_connection_test_config(request):
"""Create 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,
},
}
)
-1
View File
@@ -16,7 +16,6 @@ class FeatureFlag:
"file_upload": "FILE_UPLOAD_ENABLED",
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
}
@classmethod
+8 -8
View File
@@ -75,13 +75,13 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
scope = "creation_callback"
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
"""Throttle the LiveKit SIP module requesting roomkit joins.
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
The roomkit endpoints are authenticated as a machine user, so all requests
share a single throttle bucket. This is not a security measure against
brute-force attacks but a guard against accidental hammering from a buggy
SIP module.
"""
scope = "connection_test"
scope = "roomkit_join"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
+1 -8
View File
@@ -429,14 +429,7 @@ class Room(Resource):
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
# Roomkit devices also join by PIN, so a PIN is needed as soon as
# either integration is enabled.
if (
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
and not self.pk
and not self.pin_code
):
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
-1
View File
@@ -1 +0,0 @@
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
@@ -1,65 +0,0 @@
"""Authentication for the roomkit API of the Meet core app."""
import logging
import secrets
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from core.recording.event.authentication import MachineUser
logger = logging.getLogger(__name__)
class ServerToServerAuthentication(BaseAuthentication):
"""Custom authentication class for roomkit server-to-server requests.
Validates the Authorization header against the roomkit server-to-server
token. A valid PIN code is intentionally not enough to authenticate: the
endpoints are restricted to the LiveKit SIP module's credentials.
"""
AUTH_HEADER = "Authorization"
TOKEN_TYPE = "Bearer" # noqa S105
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header.
Returns a (MachineUser, token) pair on success, and raises
AuthenticationFailed if the header is missing, malformed, or contains
an invalid token.
"""
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
if not required_token:
raise AuthenticationFailed("Server-to-server token is not configured.")
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
logger.warning(
"Roomkit authentication failed: missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Authorization header is missing.")
# Validate token format and existence
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
# Use constant-time comparison to prevent timing attacks
if not secrets.compare_digest(token.encode(), required_token.encode()):
logger.warning(
"Roomkit authentication failed: invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid server-to-server token.")
return MachineUser(username="roomkit"), token
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
-21
View File
@@ -1,21 +0,0 @@
"""Serializers for the roomkit API of the Meet core app."""
# pylint: disable=abstract-method
from django.conf import settings
from rest_framework import serializers
from core.api.serializers import BaseValidationOnlySerializer
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
"""Validate roomkit join requests from the LiveKit SIP module."""
pin_code = serializers.CharField(required=True)
def validate_pin_code(self, value):
"""Ensure the PIN code matches the configured length."""
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
raise serializers.ValidationError("PIN code length is invalid.")
return value
-89
View File
@@ -1,89 +0,0 @@
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
from logging import getLogger
from rest_framework import decorators, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
response as drf_response,
)
from rest_framework import (
status as drf_status,
)
from core import analytics, models
from core.api import permissions, throttling
from core.api.feature_flag import FeatureFlag
from core.services.sip_management import SIPException, SIPManagement
from . import authentication, serializers
logger = getLogger(__name__)
class RoomKitViewSet(viewsets.ViewSet):
"""Server-to-server API endpoints for the roomkit integration.
Groups all interactions between roomkit (SIP) devices and the backend,
brokered by the LiveKit SIP module. All endpoints are authenticated
with the roomkit server-to-server tokens.
"""
authentication_classes = [authentication.ServerToServerAuthentication]
permission_classes = [permissions.IsAuthenticated]
@decorators.action(
detail=False,
methods=["post"],
url_path="join",
throttle_classes=[throttling.RoomKitJoinRateThrottle],
)
@FeatureFlag.require("roomkit")
def join(self, request):
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
Called by the LiveKit SIP module when a meeting-room device dials in
with a PIN code before any WebRTC participant has joined. Resolves the
room by PIN and creates its SIP dispatch rule, so the device can enter
without waiting for a WebRTC user.
The webhook-based creation path is kept: both converge on the same rule
through the shared SIPManagement.
"""
serializer = serializers.RoomKitJoinSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
room = models.Room.objects.get(
pin_code=serializer.validated_data["pin_code"]
)
except models.Room.DoesNotExist as e:
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
try:
created = SIPManagement().ensure_dispatch_rule(room)
except SIPException as e:
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
analytics.capture(
request.user,
analytics.AnalyticsEvent.ROOMKIT_JOINED,
{
"room_id": str(room.pk),
"dispatch_rule_created": created,
},
)
logger.info(
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
room.id,
created,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
+24 -10
View File
@@ -28,7 +28,7 @@ from .room_management import (
RoomManagementException,
RoomNotFoundException,
)
from .sip_management import SIPException, SIPManagement
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -107,7 +107,7 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.sip_management = SIPManagement()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
self._filter_regex = None
@@ -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
@@ -228,6 +235,13 @@ 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 isinstance(room_name, str) and room_name.startswith(
settings.CONNECTION_TEST_ROOM_PREFIX
)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -245,12 +259,12 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create sip dispatch rule for room {room_id}"
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
@@ -265,12 +279,12 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.sip_management.delete_dispatch_rule(room_id)
except SIPException as e:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete sip dispatch rule for room {room_id}"
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
@@ -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()
@@ -1,9 +1,9 @@
"""SIP management service for managing SIP dispatch rules for room access."""
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError, TwirpErrorCode
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
@@ -17,16 +17,12 @@ from core import utils
logger = getLogger(__name__)
class SIPException(Exception):
"""Exception raised when SIP operations fail."""
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class DispatchRuleConflictError(SIPException):
"""Raised when a dispatch rule already exists for the same routing criteria."""
class SIPManagement:
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
@@ -36,7 +32,7 @@ class SIPManagement:
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures livekit-sip to route incoming SIP calls directly to the specified room
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
@@ -55,12 +51,10 @@ class SIPManagement:
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
if e.code == TwirpErrorCode.ALREADY_EXISTS:
raise DispatchRuleConflictError("Dispatch rule already exists") from e
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise SIPException("Could not create dispatch rule") from e
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
@@ -85,7 +79,7 @@ class SIPManagement:
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise SIPException("Could not list dispatch rules") from e
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
@@ -100,28 +94,6 @@ class SIPManagement:
if existing_rule.name == rule_name
]
@async_to_sync
async def has_dispatch_rule(self, room_id):
"""Check whether at least one dispatch rule exists for a specific room."""
return bool(await self._list_dispatch_rules_ids(room_id))
def ensure_dispatch_rule(self, room):
"""Create the SIP dispatch rule for a room if it does not already exist.
Returns:
bool: True if a rule was created, False if it already existed.
"""
if self.has_dispatch_rule(room.pk):
return False
try:
self.create_dispatch_rule(room)
except DispatchRuleConflictError:
return False
return True
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
@@ -146,7 +118,7 @@ class SIPManagement:
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise SIPException("Could not delete dispatch rules") from e
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
+9
View File
@@ -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",
)
+39
View File
@@ -0,0 +1,39 @@
"""Tasks related to connection test rooms."""
import logging
from django.conf import settings
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.tasks._task import task
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
try:
RoomManagement().delete_room(room_name)
except RoomNotFoundException:
# Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name)
except RoomManagementException:
logger.exception("Failed to delete connection test room '%s'.", room_name)
@@ -1 +0,0 @@
"""Tests for the roomkit API of the Meet core app."""
@@ -1,266 +0,0 @@
"""
Test the roomkit join server-to-server API endpoint.
"""
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
import pytest
from ...factories import RoomFactory
from ...services.sip_management import SIPException
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_sip_management():
"""Mock the SIPManagement used by the roomkit viewset."""
with mock.patch("core.roomkit.viewsets.SIPManagement") as mock_service_class:
yield mock_service_class.return_value
def test_join_anonymous(settings, mock_sip_management, client):
"""Requests without an Authorization header should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
assert response.status_code == 401
assert response.json() == {"detail": "Authorization header is missing."}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_malformed_authorization_header(settings, mock_sip_management, client):
"""Requests with a malformed Authorization header should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="testAuthToken",
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid authorization header."}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_wrong_bearer(settings, mock_sip_management, client):
"""Requests with an incorrect bearer token should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid server-to-server token."}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_token_not_configured(settings, mock_sip_management, client):
"""Requests should be rejected when no server-to-server token is configured."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 401
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_roomkit_disabled(settings, mock_sip_management, client):
"""The endpoint should not be exposed when the roomkit integration is disabled."""
settings.ROOMKIT_ENABLED = False
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_missing_pin(settings, mock_sip_management, client):
"""Requests without a PIN code should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/roomkit/join/",
{},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["This field is required."]}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_blank_pin(settings, mock_sip_management, client):
"""Requests with a blank PIN code should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": ""},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["This field may not be blank."]}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_wrong_pin_length(settings, mock_sip_management, client):
"""Requests with a PIN code of unexpected length should be rejected."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": "123"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 400
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_unknown_pin(settings, mock_sip_management, client):
"""Requests with a PIN matching no room should return 404 and create no rule."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
RoomFactory(pin_code="1234567890")
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": "0987654321"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "No room found for this PIN code."}
mock_sip_management.ensure_dispatch_rule.assert_not_called()
def test_join_success(settings, mock_sip_management, client):
"""Requests with a valid PIN should create the dispatch rule."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_management.ensure_dispatch_rule.return_value = True
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
def test_join_dispatch_rule_already_exists(settings, mock_sip_management, client):
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_management.ensure_dispatch_rule.return_value = False
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
def test_join_tracks_analytics_event(settings, mock_sip_management, client):
"""Successful joins should be tracked with an analytics event."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_management.ensure_dispatch_rule.return_value = True
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
mock_capture.assert_called_once()
_user, event, properties = mock_capture.call_args[0]
assert str(event) == "roomkit_joined"
assert properties == {
"room_id": str(room.pk),
"dispatch_rule_created": True,
}
def test_join_sip_failure(settings, mock_sip_management, client):
"""Requests should fail with a server error when the sip management service fails."""
settings.ROOMKIT_ENABLED = True
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
room = RoomFactory(pin_code="1234567890")
mock_sip_management.ensure_dispatch_rule.side_effect = SIPException(
"Could not create dispatch rule"
)
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
response = client.post(
"/api/v1.0/roomkit/join/",
{"pin_code": room.pin_code},
HTTP_AUTHORIZATION="Bearer testAuthToken",
raise_request_exception=False,
)
assert response.status_code == 500
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
mock_capture.assert_not_called()
@@ -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
@@ -21,10 +23,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.sip_management import (
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -62,7 +61,7 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.sip_management, SIPManagement)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@@ -472,11 +471,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
@@ -489,31 +488,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
@@ -527,7 +507,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
@@ -550,9 +530,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
SIPManagement,
TelephonyService,
"delete_dispatch_rule",
side_effect=SIPException("Test error"),
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
@@ -563,7 +543,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete sip dispatch rule for room "
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
@@ -573,6 +553,34 @@ 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(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()."""
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()
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()
@@ -584,11 +592,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
service._handle_room_finished(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
@@ -596,75 +604,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
mock_ensure_dispatch_rule, service, settings
):
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
def test_handle_room_started_ignores_existing_dispatch_rule(
mock_ensure_dispatch_rule, service, settings
):
"""Should proceed silently when the dispatch rule already exists when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Test error"),
)
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
mock_ensure_dispatch_rule, service, settings
):
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_ensure_dispatch_rule, service, settings
mock_create_dispatch_rule, service, settings
):
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_ensure_dispatch_rule.assert_not_called()
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
@@ -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()
@@ -1,5 +1,5 @@
"""
Test SIP mamagement service.
Test telephony service.
"""
# pylint: disable=W0212
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.sip_management import (
DispatchRuleConflictError,
SIPException,
SIPManagement,
)
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
def test_rule_name():
"""Test rule name generation."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = sip_management._rule_name(room.id)
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@@ -49,14 +45,14 @@ def test_rule_name():
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
sip_management.create_dispatch_rule(room)
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
@@ -71,7 +67,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.create_dispatch_rule(room)
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@@ -90,7 +86,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
@@ -131,7 +127,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -149,7 +145,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
)
mock_client_factory.return_value = mock_api
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@@ -176,7 +172,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not list dispatch rules"):
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -220,7 +216,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -242,7 +238,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
@@ -257,11 +253,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
sip_management = SIPManagement()
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
sip_management.delete_dispatch_rule(room.id)
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(
msg=(
"Dispatch rule for the same trunk, inbound number, number, and "
"PIN combination already exists in dispatch rule"
),
code="already_exists",
status=409,
)
)
mock_client_factory.return_value = mock_api
with pytest.raises(DispatchRuleConflictError):
sip_management.create_dispatch_rule(room)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
"""Test that ensure_dispatch_rule creates the rule when none exists."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is True
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
existing_rule = SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
)
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is False
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
between the existence check and the creation, LiveKit rejects the
duplicate and ensure_dispatch_rule reports the rule as already existing.
"""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(
msg=(
"Dispatch rule for the same trunk, inbound number, number, and "
"PIN combination already exists in dispatch rule"
),
code="already_exists",
status=409,
)
)
mock_client_factory.return_value = mock_api
created = sip_management.ensure_dispatch_rule(room)
assert created is False
@mock.patch("core.utils.create_livekit_client")
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(SIPException, match="Could not create dispatch rule"):
sip_management.ensure_dispatch_rule(room)
@@ -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
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_calls_room_management(mock_delete_room):
"""RoomManagement.delete_room is called for connection-test rooms."""
delete_connection_test_room("connection-test-abc")
mock_delete_room.assert_called_once_with("connection-test-abc")
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room):
"""Refuse to delete rooms outside the connection-test namespace."""
delete_connection_test_room("production-room")
mock_delete_room.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room):
"""Missing rooms are treated as already cleaned up."""
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")
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
def test_delete_connection_test_room_logs_other_failures(mock_delete_room):
"""Unexpected LiveKit failures are swallowed after logging."""
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,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.post("/api/v1.0/connection-test/")
response_b = client.post("/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.post("/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.post("/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.post("/api/v1.0/connection-test/")
assert response.status_code == 200
mock_apply_async.assert_not_called()
+2 -15
View File
@@ -184,13 +184,12 @@ def test_models_rooms_is_public_property():
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_and_roomkit_disabled_skips_pin_generation(
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony and roomkit both disabled should not generate pin codes."""
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
assert room.pin_code is None
def test_roomkit_enabled_generates_pin_code(settings):
"""Roomkit enabled alone should generate pin codes, even without telephony."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
assert room.pin_code is not None
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
+6 -6
View File
@@ -8,8 +8,8 @@ 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 create_connection_test_config
from core.external_api import viewsets as external_viewsets
from core.roomkit import viewsets as roomkit_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -20,11 +20,6 @@ router.register("files", viewsets.FileViewSet, basename="files")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
router.register(
"roomkit",
roomkit_viewsets.RoomKitViewSet,
basename="roomkit",
)
router.register(
"addons/sessions",
addons_viewsets.SessionViewSet,
@@ -52,6 +47,11 @@ urlpatterns = [
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path(
"connection-test/",
create_connection_test_config,
name="connection_test",
),
]
),
),
+5
View File
@@ -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()
+18 -18
View File
@@ -349,9 +349,9 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"roomkit_join": values.Value(
default="300/minute",
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
@@ -660,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
)
@@ -886,21 +901,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Roomkit (meeting-room SIP devices) integration
ROOMKIT_ENABLED = values.BooleanValue(
False,
environ_name="ROOMKIT_ENABLED",
environ_prefix=None,
)
# Server-to-server API token allowing the LiveKit SIP module to call the
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
# dialing in before any WebRTC participant).
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
None,
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
environ_prefix=None,
)
# Subtitles settings
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
@@ -0,0 +1,15 @@
import { fetchApi } from '@/api/fetchApi'
export type ConnectionTestTokenResponse = {
livekit: {
url: string
room: string
token: string
expires_in: number
}
}
export const fetchConnectionTestToken = () =>
fetchApi<ConnectionTestTokenResponse>('/connection-test/', {
method: 'POST',
})
@@ -0,0 +1,245 @@
import { useRef, useState } from 'react'
import {
CheckStatus,
ConnectionCheck,
createLocalAudioTrack,
createLocalVideoTrack,
getBrowser,
type CheckInfo,
type LocalVideoTrack,
} from 'livekit-client'
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
import {
createInitialSteps,
type ConnectionTestLog,
type ConnectionTestStepId,
type ConnectionTestStepResult,
type ConnectionTestStepStatus,
} from '../types'
import { openPermissionsDialog } from '@/stores/permissions'
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
[CheckStatus.IDLE]: 'pending',
[CheckStatus.RUNNING]: 'running',
[CheckStatus.SUCCESS]: 'success',
[CheckStatus.FAILED]: 'failed',
[CheckStatus.SKIPPED]: 'skipped',
}
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
error instanceof Error ? error.message : fallback
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
logs: info.logs,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
const grouped: Record<MediaDeviceKind, string[]> = {
audioinput: [],
audiooutput: [],
videoinput: [],
}
for (const device of devices) {
grouped[device.kind].push(device.label || device.deviceId)
}
return grouped
}
export const useConnectionTestRunner = () => {
const [steps, setSteps] = useState(createInitialSteps)
const [isRunning, setIsRunning] = useState(false)
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
const abortRef = useRef<AbortController | null>(null)
const updateStep = (
id: ConnectionTestStepId,
patch: Partial<ConnectionTestStepResult>
) => {
setSteps((current) =>
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
)
}
const stopVideoTrack = () => {
videoTrackRef.current?.stop()
videoTrackRef.current = null
setVideoTrack(null)
}
const skipSteps = (
ids: ConnectionTestStepId[],
summary: string,
logs?: ConnectionTestLog[]
) => {
for (const id of ids) {
updateStep(id, { status: 'skipped', summary, logs })
}
}
/** Returns true on success, false on failure, null if aborted. */
const runStep = async (
id: ConnectionTestStepId,
signal: AbortSignal,
fn: () => Promise<Partial<ConnectionTestStepResult>>
): Promise<boolean | null> => {
if (signal.aborted) return null
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
try {
const result = await fn()
if (signal.aborted) return null
// `result.status` overrides when set (LiveKit checks map their own status)
updateStep(id, { status: 'success', ...result })
return true
} catch (error) {
if (signal.aborted) return null
updateStep(id, {
status: 'failed',
summary: getErrorMessage(error),
})
return false
}
}
const runTest = async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const { signal } = controller
setIsRunning(true)
setSteps(createInitialSteps())
stopVideoTrack()
try {
await runStep('browser', signal, async () => {
const browser = getBrowser()
if (!browser) throw new Error('Browser not detected')
return {
summary: `${browser.name} ${browser.version}`,
data: {
name: browser.name,
version: browser.version,
os: browser.os,
osVersion: browser.osVersion,
},
}
})
if (signal.aborted) return
const microphoneOk = await runStep('microphone', signal, async () => {
const track = await createLocalAudioTrack()
const label =
track.mediaStreamTrack.label ||
track.mediaStreamTrack.getSettings().deviceId ||
''
track.stop()
return { summary: label, data: { label } }
})
if (signal.aborted) return
if (!microphoneOk) openPermissionsDialog('audioinput')
const cameraOk = await runStep('camera', signal, async () => {
const track = await createLocalVideoTrack()
videoTrackRef.current = track
setVideoTrack(track)
const settings = track.mediaStreamTrack.getSettings()
const label = track.mediaStreamTrack.label || ''
return {
summary: label,
data: {
label,
width: settings.width,
height: settings.height,
},
}
})
if (signal.aborted) return
if (!cameraOk) openPermissionsDialog('videoinput')
await runStep('devices', signal, async () => {
const devices = await navigator.mediaDevices.enumerateDevices()
return {
summary: String(devices.length),
data: groupDevicesByKind(devices),
}
})
if (signal.aborted) return
let checker: ConnectionCheck
try {
const { livekit } = await fetchConnectionTestToken()
checker = new ConnectionCheck(livekit.url, livekit.token)
} catch (error) {
skipSteps(
LIVEKIT_STEP_IDS,
getErrorMessage(error, 'Failed to fetch test token')
)
return
}
await runStep('websocket', signal, async () =>
fromCheckInfo(await checker.checkWebsocket())
)
await runStep('webrtc', signal, async () =>
fromCheckInfo(await checker.checkWebRTC())
)
await runStep('turn', signal, async () =>
fromCheckInfo(await checker.checkTURN())
)
await runStep('reconnect', signal, async () =>
fromCheckInfo(await checker.checkReconnect())
)
if (!microphoneOk) {
skipSteps(['publishAudio'], 'Microphone permission required')
} else {
await runStep('publishAudio', signal, async () =>
fromCheckInfo(await checker.checkPublishAudio())
)
}
if (!cameraOk) {
skipSteps(['publishVideo'], 'Camera permission required')
} else {
stopVideoTrack()
await runStep('publishVideo', signal, async () =>
fromCheckInfo(await checker.checkPublishVideo())
)
}
} finally {
if (!signal.aborted) {
stopVideoTrack()
setIsRunning(false)
}
}
}
const reset = () => {
abortRef.current?.abort()
stopVideoTrack()
setSteps(createInitialSteps())
setIsRunning(false)
}
return {
steps,
isRunning,
videoTrack,
runTest,
reset,
}
}
@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { Box, Button, Text, Ul } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import { Permissions } from '@/features/rooms/components/Permissions'
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
const { t } = useTranslation('connectionTest')
const [showDetails, setShowDetails] = useState(false)
const hasLogs = Boolean(step.logs?.length)
const statusLabel = t(`status.${step.status}`)
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
const statusVariant =
step.status === 'failed'
? 'warning'
: step.status === 'success'
? 'body'
: 'smNote'
return (
<VStack gap="0.25rem" alignItems="stretch" width="100%">
<HStack
justifyContent="space-between"
alignItems="flex-start"
width="100%"
>
<Text variant="bodyXsMedium">{stepLabel}</Text>
{step.status === 'running' ? (
<Spinner size={20} />
) : (
<Text variant={statusVariant}>{statusLabel}</Text>
)}
</HStack>
{step.summary && (
<Text variant="smNote" margin={false}>
{step.summary}
</Text>
)}
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
<Button
variant="secondaryText"
size="sm"
onPress={() => setShowDetails((open) => !open)}
>
{t('details')}
</Button>
)}
{showDetails && step.logs && (
<Ul>
{step.logs.map((log, index) => (
<li key={index}>
<Text variant="xsNote" as="span">
{log.message}
</Text>
</li>
))}
</Ul>
)}
</VStack>
)
}
const ConnectionTest = () => {
const { t } = useTranslation('connectionTest')
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
const videoRef = useRef<HTMLVideoElement>(null)
const hasStarted = steps.some((step) => step.status !== 'pending')
const hasFailed = steps.some((step) => step.status === 'failed')
const isPublishVideoRunning = steps.some(
(step) => step.id === 'publishVideo' && step.status === 'running'
)
useEffect(() => {
const element = videoRef.current
if (!element || !videoTrack) return
videoTrack.attach(element)
return () => {
videoTrack.detach(element)
}
}, [videoTrack])
// LiveKit appends a bare <video> to document.body during publishVideo.
// Keep it in the DOM (so the frame check still works) but hide it visually.
useEffect(() => {
document.body.classList.toggle(
HIDE_LIVEKIT_VIDEO_CLASS,
isPublishVideoRunning
)
return () => {
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
}
}, [isPublishVideoRunning])
return (
<Screen layout="centered">
<Permissions />
<CenteredContent title={t('title')} withBackButton>
<Center>
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
<Text as="p" variant="paragraph" centered last>
{t('intro')}
</Text>
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
{hasStarted ? t('reset') : t('runTest')}
</Button>
{videoTrack && (
<Center>
<video ref={videoRef} autoPlay playsInline muted />
</Center>
)}
{hasStarted && (
<Box variant="light" width="100%">
<VStack gap="1rem" alignItems="stretch">
{steps.map((step) => (
<TestStepItem key={step.id} step={step} />
))}
</VStack>
</Box>
)}
{hasFailed && !isRunning && (
<Text variant="smNote" centered>
{t('help.firewall')}
</Text>
)}
{hasStarted && !isRunning && (
<Button
variant="secondary"
onPress={() => downloadConnectionTestReport(steps)}
>
{t('downloadReport')}
</Button>
)}
</VStack>
</Center>
</CenteredContent>
</Screen>
)
}
export default ConnectionTest
@@ -0,0 +1,47 @@
export type ConnectionTestStepId =
| 'browser'
| 'microphone'
| 'camera'
| 'devices'
| 'websocket'
| 'webrtc'
| 'turn'
| 'reconnect'
| 'publishAudio'
| 'publishVideo'
export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'skipped'
export type ConnectionTestLog = {
level: 'info' | 'warning' | 'error'
message: string
}
export type ConnectionTestStepResult = {
id: ConnectionTestStepId
status: ConnectionTestStepStatus
summary?: string
logs?: ConnectionTestLog[]
data?: Record<string, unknown>
}
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
'browser',
'microphone',
'camera',
'devices',
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
export const createInitialSteps = (): ConnectionTestStepResult[] =>
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
@@ -0,0 +1,49 @@
import type { ConnectionTestStepResult } from '../types'
export type ConnectionTestReport = {
generatedAt: string
userAgent: string
steps: Record<
string,
{
status: ConnectionTestStepResult['status']
summary?: string
logs?: ConnectionTestStepResult['logs']
data?: ConnectionTestStepResult['data']
}
>
}
export const buildConnectionTestReport = (
steps: ConnectionTestStepResult[]
): ConnectionTestReport => ({
generatedAt: new Date().toISOString(),
userAgent: navigator.userAgent,
steps: Object.fromEntries(
steps.map(({ id, status, summary, logs, data }) => [
id,
{
status,
...(summary !== undefined ? { summary } : {}),
...(logs?.length ? { logs } : {}),
...(data !== undefined ? { data } : {}),
},
])
),
})
export const downloadConnectionTestReport = (
steps: ConnectionTestStepResult[]
) => {
const report = buildConnectionTestReport(steps)
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
const blob = new Blob([JSON.stringify(report, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `connection-test-${timestamp}.json`
anchor.click()
URL.revokeObjectURL(url)
}
+10
View File
@@ -266,6 +266,16 @@ export const Footer = () => {
{t('links.accessibility')}
</Link>
</StyledLi>
<StyledLi divider>
<Link
underline={false}
footer="minor"
to="/test-connection"
aria-label={t('links.connectionTest')}
>
{t('links.connectionTest')}
</Link>
</StyledLi>
<StyledLi>
<A
externalIcon
@@ -0,0 +1,31 @@
{
"title": "Test your configuration",
"intro": "Check that your device works with Visio: browser, media devices, and server connectivity.",
"runTest": "Run test",
"reset": "Run again",
"details": "Details",
"downloadReport": "Download report",
"homeLink": "Test your configuration",
"steps": {
"browser": "Browser",
"microphone": "Microphone",
"camera": "Camera",
"devices": "Media devices",
"websocket": "WebSocket",
"webrtc": "WebRTC",
"turn": "TURN",
"reconnect": "Reconnect",
"publishAudio": "Audio publishing",
"publishVideo": "Video publishing"
},
"status": {
"pending": "Pending",
"running": "Running…",
"success": "Passed",
"failed": "Failed",
"skipped": "Skipped"
},
"help": {
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
}
}
+1
View File
@@ -36,6 +36,7 @@
"legalsTerms": "Legal Notice",
"data": "Personal Data and Cookies",
"accessibility": "Accessibility: non-compliant",
"connectionTest": "Test your configuration",
"ariaLabel": "new window",
"codeAnnotation": "Our code is open and available on this",
"code": "Open Source Code Repository",
+1
View File
@@ -13,6 +13,7 @@
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
"connectionTestLink": "Test your configuration",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
@@ -0,0 +1,31 @@
{
"title": "Tester votre configuration",
"intro": "Vérifiez la compatibilité de votre poste avec Visio : navigateur, périphériques médias et connexion au serveur.",
"runTest": "Lancer le test",
"reset": "Relancer",
"details": "Détails",
"downloadReport": "Télécharger le rapport",
"homeLink": "Tester votre configuration",
"steps": {
"browser": "Navigateur",
"microphone": "Microphone",
"camera": "Caméra",
"devices": "Périphériques médias",
"websocket": "WebSocket",
"webrtc": "WebRTC",
"turn": "TURN",
"reconnect": "Reconnexion",
"publishAudio": "Publication audio",
"publishVideo": "Publication vidéo"
},
"status": {
"pending": "En attente",
"running": "En cours…",
"success": "Réussi",
"failed": "Échec",
"skipped": "Ignoré"
},
"help": {
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
}
}
+1
View File
@@ -36,6 +36,7 @@
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilité : non conforme",
"connectionTest": "Tester votre configuration",
"ariaLabel": "nouvelle fenêtre",
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
"code": "dépôt de code Open Source",
+1
View File
@@ -13,6 +13,7 @@
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}",
"connectionTestLink": "Tester votre configuration",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
+9
View File
@@ -20,6 +20,9 @@ const AccessibilityRoute = lazy(
)
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
const ConnectionTestRoute = lazy(
() => import('@/features/connection-test/routes/ConnectionTest')
)
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
@@ -27,6 +30,7 @@ export const routes: Record<
| 'home'
| 'room'
| 'feedback'
| 'connectionTest'
| 'legalTerms'
| 'accessibility'
| 'termsOfService'
@@ -57,6 +61,11 @@ export const routes: Record<
path: '/feedback',
Component: FeedbackRoute,
},
connectionTest: {
name: 'connectionTest',
path: '/test-connection',
Component: ConnectionTestRoute,
},
legalTerms: {
name: 'legalTerms',
path: '/mentions-legales',
+13
View File
@@ -31,6 +31,19 @@ html.font-opendyslexic {
border: 0;
}
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
Keep it decodable (not display:none) but invisible. */
body.connection-test-hide-livekit-video > video {
position: fixed;
top: 0;
left: 0;
width: 10px;
height: 10px;
opacity: 0;
pointer-events: none;
z-index: -1;
}
* {
outline: 2px solid transparent;
}