mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-01 14:42:15 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5790bb1e47 | |||
| ca80540f7e | |||
| 80f3af0690 | |||
| 399617e247 |
@@ -14,6 +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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -26,6 +27,7 @@ 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
|
||||
|
||||
|
||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -16,7 +16,7 @@ class FeatureFlag:
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -580,25 +580,3 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||
|
||||
# todo if I can pass the max length directly to the char field
|
||||
code = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||
|
||||
def validate_code(self, value):
|
||||
"""Reject codes whose length cannot match a generated one.
|
||||
|
||||
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
|
||||
url-safe characters. Checking the length against the configured
|
||||
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
|
||||
before any cache lookup.
|
||||
"""
|
||||
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
if len(value) != expected_length:
|
||||
raise serializers.ValidationError("Invalid transit code format.")
|
||||
|
||||
return value
|
||||
|
||||
@@ -75,12 +75,13 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
||||
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
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 = "exchange_access_token"
|
||||
scope = "roomkit_join"
|
||||
|
||||
@@ -69,7 +69,6 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -94,7 +93,6 @@ from core.services.room_roles import (
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
@@ -231,76 +229,6 @@ class UserViewSet(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="exchange-access-token",
|
||||
permission_classes=[],
|
||||
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def exchange_access_token(self, request):
|
||||
"""Exchange a single-use transit code for a user access token.
|
||||
|
||||
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||
random string obtained through the external API and delivered to
|
||||
the embedded frontend via a URL fragment, is the credential. Each
|
||||
code can be exchanged exactly once (consuming it deletes it from
|
||||
the cache); replaying a consumed code is denied and logged.
|
||||
|
||||
The issued JWT authenticates the user the code was minted for on
|
||||
the whole core API, exactly like a session cookie would (similar
|
||||
to lib-jitsi-meet's token authentication), and never appears in
|
||||
any URL. Role-based permissions apply unchanged.
|
||||
"""
|
||||
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||
|
||||
if code_data is None:
|
||||
logger.warning("Invalid, expired or already used transit code")
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"Invalid, expired or already used transit code."
|
||||
)
|
||||
|
||||
# Re-check the user at exchange time so that a deactivation after
|
||||
# the transit code was minted is taken into account.
|
||||
try:
|
||||
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||
except models.User.DoesNotExist as excpt:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"This account can no longer access the application."
|
||||
) from excpt
|
||||
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
# todo - discuss wether it's the relevant scope
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
"user:access",
|
||||
{
|
||||
"token_type": "user_access",
|
||||
"client_id": code_data.get("client_id", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||
user.id,
|
||||
code_data.get("client_id", "unknown"),
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""User access JWT authentication for the Meet core API.
|
||||
|
||||
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||
session cookies are blocked) to authenticate requests on the core API with
|
||||
a JWT, obtained by exchanging a single-use transit code (see
|
||||
core.services.transit_code and the users exchange-access-token endpoint)
|
||||
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||
|
||||
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||
user, not to a resource: once authenticated, the request is treated
|
||||
exactly like a session-authenticated one, and the existing role-based
|
||||
permissions apply unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from core.external_api.authentication import BaseJWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||
|
||||
|
||||
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for user access tokens.
|
||||
|
||||
Validates user access tokens issued by the users exchange-access-token
|
||||
endpoint and authenticates the user they were issued for. A bearer
|
||||
token that does not verify against the user access token secret is
|
||||
deferred to the next authentication backend; a token that does verify
|
||||
but carries wrong claims is rejected.
|
||||
|
||||
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||
returns None before reading the Authorization header, deferring every
|
||||
request to the next authentication backend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the backend with user access token settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate the token type and the issuance-audit claim.
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the token verified against the user
|
||||
access token secret but does not carry the expected claims.
|
||||
"""
|
||||
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||
logger.warning("Wrong 'token_type' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
# Every token we issue carries the client_id of the application the
|
||||
# transit code was minted for: its absence means the token does not
|
||||
# come from the exchange endpoint.
|
||||
if not payload.get("client_id"):
|
||||
logger.warning("Missing 'client_id' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
@@ -86,14 +86,6 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
}
|
||||
|
||||
|
||||
class HasRequiredUserScope(BaseScopePermission):
|
||||
"""Scope-based permissions for the external user endpoints."""
|
||||
|
||||
scope_map = {
|
||||
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -219,62 +218,3 @@ class RoomViewSet(
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
"""Application-delegated API for user operations.
|
||||
|
||||
Provides JWT-authenticated access to user operations for external
|
||||
applications acting on behalf of users. All operations are
|
||||
scope-based. Meant to grow with the other user actions exposed to
|
||||
third parties.
|
||||
|
||||
Supported operations:
|
||||
- transit-code: Mint a single-use transit code for the delegated user
|
||||
(requires 'users:session' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||
]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="transit-code",
|
||||
url_name="transit-code",
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def generate_transit_code(self, request):
|
||||
"""Mint a transit code for the delegated user.
|
||||
|
||||
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||
frontend exchanges it once on
|
||||
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||
equivalent to session-cookie authentication and never exposed in a URL.
|
||||
"""
|
||||
auth_method = type(request.successful_authenticator).__name__
|
||||
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
|
||||
request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"transit_code": code,
|
||||
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='scopes',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||
),
|
||||
]
|
||||
@@ -429,7 +429,14 @@ class Room(Resource):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
|
||||
# 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
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
@@ -769,7 +776,6 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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'"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""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
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -28,7 +28,7 @@ from .room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.sip_management = SIPManagement()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
self._filter_regex = None
|
||||
@@ -245,12 +245,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:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||
f"Failed to create sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.delete_dispatch_rule(room_id)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
|
||||
+38
-10
@@ -1,9 +1,9 @@
|
||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||
"""SIP management 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
|
||||
from livekit.api import TwirpError, TwirpErrorCode
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
@@ -17,12 +17,16 @@ from core import utils
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
class SIPException(Exception):
|
||||
"""Exception raised when SIP operations fail."""
|
||||
|
||||
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
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)."""
|
||||
|
||||
def _rule_name(self, room_id):
|
||||
"""Generate the rule name for a room based on its ID."""
|
||||
@@ -32,7 +36,7 @@ class TelephonyService:
|
||||
async def create_dispatch_rule(self, room):
|
||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||
|
||||
Configures telephony to route incoming SIP calls directly to the specified room
|
||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
||||
using the room's ID and PIN code for authentication.
|
||||
"""
|
||||
|
||||
@@ -51,10 +55,12 @@ class TelephonyService:
|
||||
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 TelephonyException("Could not create dispatch rule") from e
|
||||
raise SIPException("Could not create dispatch rule") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -79,7 +85,7 @@ class TelephonyService:
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not list dispatch rules") from e
|
||||
raise SIPException("Could not list dispatch rules") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
||||
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."""
|
||||
@@ -118,7 +146,7 @@ class TelephonyService:
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not delete dispatch rules") from e
|
||||
raise SIPException("Could not delete dispatch rules") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Service handling the lifecycle of transit codes.
|
||||
|
||||
A transit code is an opaque, cryptographically random, single-use code
|
||||
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||
user access token on the core API without a session cookie. The code
|
||||
carries no information by itself: everything it references (user, client)
|
||||
is stored server-side in the cache, and consumed atomically on exchange.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class TransitCodeService:
|
||||
"""Create and consume single-use transit codes."""
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(code):
|
||||
"""Build the cache key for a code.
|
||||
|
||||
The code is hashed so that a dump of the cache never reveals
|
||||
directly usable codes.
|
||||
"""
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||
|
||||
def create_code(self, user, client_id="unknown"):
|
||||
"""Generate a transit code for a user, and store it.
|
||||
|
||||
The code expires after TRANSIT_CODE_TTL seconds.
|
||||
|
||||
Returns:
|
||||
str: The opaque code to hand to the client.
|
||||
"""
|
||||
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||
# entropy: unguessable and safe to transit through a URL fragment.
|
||||
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
cache.set(
|
||||
self._cache_key(code),
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=settings.TRANSIT_CODE_TTL,
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
def consume_code(self, code):
|
||||
"""Consume a transit code, enforcing single use.
|
||||
|
||||
The code is deleted from the cache upon consumption. `cache.delete`
|
||||
returns whether a key was actually deleted, so if two requests race
|
||||
on the same code, only one of them wins.
|
||||
|
||||
Returns:
|
||||
dict | None: The data stored at creation time ('user_id',
|
||||
'client_id'), or None if the code is unknown, expired or
|
||||
already consumed.
|
||||
"""
|
||||
if not code:
|
||||
return None
|
||||
|
||||
key = self._cache_key(code)
|
||||
data = cache.get(key)
|
||||
|
||||
if data is None or not cache.delete(key):
|
||||
return None
|
||||
|
||||
return data
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the roomkit API of the Meet core app."""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
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()
|
||||
@@ -2,14 +2,9 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -114,38 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
"""A user access token should create a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
@@ -160,40 +156,3 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
"""A user access token should list rooms exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
RoomFactory() # another user's room, not listed
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -3,14 +3,11 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -506,40 +503,3 @@ def test_api_rooms_retrieve_administrators(
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
"""A user access token should retrieve a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
# Authenticated as the owner: privileged fields are included
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
|
||||
@@ -3,12 +3,8 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -441,45 +437,3 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||
"""Role-based permissions apply unchanged with a user access token."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
# A simple member cannot update the room
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 403
|
||||
|
||||
# An administrator can
|
||||
room.accesses.filter(user=user).update(role="administrator")
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
@@ -21,7 +21,10 @@ from core.services.livekit_events import (
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -59,7 +62,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.telephony_service, TelephonyService)
|
||||
assert isinstance(service.sip_management, SIPManagement)
|
||||
assert isinstance(service.recording_events, RecordingEventsService)
|
||||
|
||||
|
||||
@@ -469,11 +472,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "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 telephony dispatch rule when room finishes."""
|
||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -486,12 +489,31 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@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")
|
||||
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)
|
||||
@@ -505,7 +527,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(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
@@ -528,9 +550,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(
|
||||
TelephonyService,
|
||||
SIPManagement,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=TelephonyException("Test error"),
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
@@ -541,7 +563,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 telephony dispatch rule for room "
|
||||
"Failed to delete sip dispatch rule for room "
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
@@ -562,11 +584,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should create telephony dispatch rule when room starts successfully."""
|
||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -574,22 +596,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
@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 skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||
"""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_create_dispatch_rule.assert_not_called()
|
||||
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")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip ensuring the SIP 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()
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
|
||||
+162
-35
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test telephony service.
|
||||
Test SIP mamagement service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.services.sip_management import (
|
||||
DispatchRuleConflictError,
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
rule_name = telephony_service._rule_name(room.id)
|
||||
rule_name = sip_management._rule_name(room.id)
|
||||
|
||||
assert rule_name == f"SIP_{str(room.id)}"
|
||||
|
||||
@@ -45,14 +49,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."""
|
||||
telephony_service = TelephonyService()
|
||||
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()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
sip_management.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"]
|
||||
@@ -67,7 +71,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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -76,8 +80,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -86,7 +90,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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "rule-1" in result
|
||||
@@ -127,7 +131,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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -136,7 +140,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -145,7 +149,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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -163,7 +167,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -172,7 +176,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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -181,34 +185,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||
async_to_sync(sip_management._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.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = []
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.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.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -216,7 +220,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 = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
@@ -226,11 +230,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -238,7 +242,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 = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||
@@ -253,11 +257,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -277,18 +281,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.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.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -298,8 +302,131 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.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)
|
||||
@@ -1,46 +0,0 @@
|
||||
"""
|
||||
Unit tests for the TransitCodeService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_code_returns_unique_opaque_codes():
|
||||
"""Each created code should be a distinct high-entropy string."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
codes = {service.create_code(user) for _ in range(5)}
|
||||
|
||||
assert len(codes) == 5
|
||||
for code in codes:
|
||||
assert len(code) >= 43
|
||||
|
||||
|
||||
def test_consume_code_returns_stored_data_once():
|
||||
"""Consuming a code should return its data exactly once."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
code = service.create_code(user, client_id="my-app")
|
||||
|
||||
assert service.consume_code(code) == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "my-app",
|
||||
}
|
||||
# Single use: a second consumption fails
|
||||
assert service.consume_code(code) is None
|
||||
|
||||
|
||||
def test_consume_code_unknown_or_empty():
|
||||
"""Unknown or empty codes should not be consumable."""
|
||||
service = TransitCodeService()
|
||||
|
||||
assert service.consume_code("unknown-code") is None
|
||||
assert service.consume_code("") is None
|
||||
assert service.consume_code(None) is None
|
||||
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
Tests for user access JWT authentication on the core API.
|
||||
|
||||
The token authenticates the user on the whole API, exactly like a session
|
||||
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||
with a user access token lives in the room test files.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import RoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_user_access_token(user, **overrides):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
payload.update(overrides)
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_user_access_token_users_me():
|
||||
"""A user access token should authenticate the user on /users/me/."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_expired():
|
||||
"""An expired user access token should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = generate_user_access_token(
|
||||
user,
|
||||
iat=now - timedelta(hours=3),
|
||||
exp=now - timedelta(hours=1),
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "token expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_invalid_signature():
|
||||
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
},
|
||||
"wrong-secret-key-padded-for-minimum-len!",
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_wrong_token_type():
|
||||
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, token_type="addons")
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token type" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_missing_client_id_claim():
|
||||
"""A token without the issuance-audit claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, client_id=None)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_inactive_user():
|
||||
"""A user access token for an inactive user should be rejected."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_feature_disabled(settings):
|
||||
"""When the feature is disabled, user access tokens should be ignored."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_does_not_break_session_authentication():
|
||||
"""A session-authenticated user should keep full access to the API."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
|
||||
|
||||
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||
"""An application-delegation JWT must not authenticate on the core API."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"client_id": "some-client",
|
||||
"delegated": True,
|
||||
"scope": "rooms:retrieve",
|
||||
},
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# The user token backend must defer (wrong signature) and the request
|
||||
# must end up unauthenticated.
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app: exchange transit code.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_user_access_token(token, settings):
|
||||
"""Decode a user access token with the token secret."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
)
|
||||
|
||||
|
||||
def generate_unknown_code(settings):
|
||||
"""Generate a well-formed code that was never stored."""
|
||||
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Return an anonymous API client with a random source IP.
|
||||
|
||||
A fresh IP per test isolates the anonymous throttle history, both
|
||||
between the tests of this module and between test runs.
|
||||
"""
|
||||
# `secrets` rather than `random`: the global random module is seeded
|
||||
# deterministically by the factories, its sequence repeats across runs.
|
||||
remote_addr = (
|
||||
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||
f".{secrets.randbelow(254) + 1}"
|
||||
)
|
||||
return APIClient(REMOTE_ADDR=remote_addr)
|
||||
|
||||
|
||||
def test_exchange_access_token_missing_code(client):
|
||||
"""The exchange endpoint should validate its input."""
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "code" in response.data
|
||||
|
||||
|
||||
def test_exchange_access_token_malformed_code(client):
|
||||
"""A code whose length cannot match a generated one should be a 400."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": "not-a-valid-code"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid transit code format" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_unknown_code(client, settings):
|
||||
"""A well-formed but unknown code should be denied."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_success(client, settings):
|
||||
"""A valid transit code should be exchangeable for an access token."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user, client_id="my-app")
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
assert response.data["scope"] == "user:access"
|
||||
|
||||
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||
assert payload["token_type"] == "user_access"
|
||||
assert payload["user_id"] == str(user.id)
|
||||
assert payload["client_id"] == "my-app"
|
||||
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
|
||||
|
||||
def test_exchange_access_token_single_use(client):
|
||||
"""A transit code should be exchangeable exactly once."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Replaying the same code must be denied
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_inactive_user(client):
|
||||
"""A code minted for a now-inactive user should be denied."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer access" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_feature_disabled(client, settings):
|
||||
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_access_token_throttled(client, settings):
|
||||
"""Anonymous exchange attempts should be rate limited."""
|
||||
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||
initial_rate = throttle_rates["exchange_access_token"]
|
||||
# The rates dict is mutated in place: restore it explicitly, the
|
||||
# `settings` fixture only rolls back attribute assignments.
|
||||
throttle_rates["exchange_access_token"] = "2/minute"
|
||||
|
||||
try:
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
finally:
|
||||
throttle_rates["exchange_access_token"] = initial_rate
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
Tests for external API /users endpoints (transit codes)
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import ApplicationScope
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid application JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
application = ApplicationFactory()
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now
|
||||
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_transit_code_requires_authentication():
|
||||
"""Minting a transit code without authentication should return 401."""
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_users_transit_code_missing_scope():
|
||||
"""A token without the 'users:session' scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_success(settings):
|
||||
"""A delegated user with the scope should be able to mint a transit code."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||
|
||||
code = response.data["transit_code"]
|
||||
# Opaque, high-entropy random string
|
||||
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
# The code is stored server-side and references the delegated user
|
||||
code_data = TransitCodeService().consume_code(code)
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": mock.ANY,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token():
|
||||
"""A resource-server-authenticated user should be able to mint a code."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||
) as mock_rs_authenticate:
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
|
||||
code_data = TransitCodeService().consume_code(response.data["transit_code"])
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "rs-client",
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token_missing_scope():
|
||||
"""A resource server token without the scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
|
||||
):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_feature_disabled(settings):
|
||||
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_transit_code_inactive_user():
|
||||
"""An inactive user should not be able to mint a transit code."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
|
||||
@@ -184,12 +184,13 @@ def test_models_rooms_is_public_property():
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
):
|
||||
"""Telephony disabled should not generate pin codes."""
|
||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
@@ -197,6 +198,18 @@ def test_telephony_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."""
|
||||
|
||||
|
||||
@@ -9,6 +9,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.external_api import viewsets as external_viewsets
|
||||
from core.roomkit import viewsets as roomkit_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -19,6 +20,11 @@ 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,
|
||||
@@ -37,11 +43,6 @@ external_router.register(
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -324,7 +324,6 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
@@ -345,16 +344,16 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"exchange_access_token": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"roomkit_join": values.Value(
|
||||
default="300/minute",
|
||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -887,6 +886,21 @@ 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
|
||||
@@ -959,61 +973,6 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# User access tokens (embedded frontend / iframe support)
|
||||
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||
None,
|
||||
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the user access token obtained through the exchange
|
||||
# endpoint. It never transits through a URL, so it can cover a full
|
||||
# meeting (default: 2 hours).
|
||||
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the single-use transit code handed to the frontend
|
||||
# through a URL fragment. Kept very short by design: it must only
|
||||
# survive the redirect and the exchange call.
|
||||
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="TRANSIT_CODE_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||
"transit-code",
|
||||
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||
48,
|
||||
environ_name="TRANSIT_CODE_NBYTES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
@@ -1311,10 +1270,6 @@ class Test(Base):
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
|
||||
USER_ACCESS_TOKEN_ENABLED = True
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
Reference in New Issue
Block a user