mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-01 22:52:16 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e701a89036 | |||
| 7fbcbc89ed |
@@ -14,7 +14,6 @@ and this project adheres to
|
|||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ and this project adheres to
|
|||||||
- 📝(legal) update terms of service
|
- 📝(legal) update terms of service
|
||||||
- 💄(frontend) render Avatar initials in uppercase
|
- 💄(frontend) render Avatar initials in uppercase
|
||||||
- 💄(frontend) improve participant name rendering in the list
|
- 💄(frontend) improve participant name rendering in the list
|
||||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class FeatureFlag:
|
|||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -580,3 +580,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
|||||||
# useless bad requests
|
# useless bad requests
|
||||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
status = 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,13 +75,12 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
"""Throttle anonymous transit code exchange attempts.
|
||||||
|
|
||||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||||
share a single throttle bucket. This is not a security measure against
|
best-effort. The security of the exchange rests on the codes'
|
||||||
brute-force attacks but a guard against accidental hammering from a buggy
|
entropy and single use.
|
||||||
SIP module.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
scope = "roomkit_join"
|
scope = "exchange_access_token"
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ from core.recording.worker.mediator import (
|
|||||||
WorkerServiceMediator,
|
WorkerServiceMediator,
|
||||||
)
|
)
|
||||||
from core.services.invitation import InvitationService
|
from core.services.invitation import InvitationService
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
LiveKitWebhookError,
|
LiveKitWebhookError,
|
||||||
@@ -93,6 +94,7 @@ from core.services.room_roles import (
|
|||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
@@ -229,6 +231,76 @@ class UserViewSet(
|
|||||||
self.serializer_class(request.user, context=context).data
|
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(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""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,6 +86,14 @@ 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):
|
class RoomPermissions(permissions.BasePermission):
|
||||||
"""Permissions applying to the room API endpoint."""
|
"""Permissions applying to the room API endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
from core.services.transit_code import TransitCodeService
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -218,3 +219,62 @@ class RoomViewSet(
|
|||||||
"$set": {"email": self.request.user.email},
|
"$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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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,14 +429,7 @@ class Room(Resource):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""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(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
@@ -776,6 +769,7 @@ class ApplicationScope(models.TextChoices):
|
|||||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||||
|
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||||
|
|
||||||
|
|
||||||
class Application(BaseModel):
|
class Application(BaseModel):
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Authentication for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework.authentication import BaseAuthentication
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
|
|
||||||
from core.recording.event.authentication import MachineUser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ServerToServerAuthentication(BaseAuthentication):
|
|
||||||
"""Custom authentication class for roomkit server-to-server requests.
|
|
||||||
|
|
||||||
Validates the Authorization header against the roomkit server-to-server
|
|
||||||
token. A valid PIN code is intentionally not enough to authenticate: the
|
|
||||||
endpoints are restricted to the LiveKit SIP module's credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
|
||||||
"""Validate the Bearer token from the Authorization header.
|
|
||||||
|
|
||||||
Returns a (MachineUser, token) pair on success, and raises
|
|
||||||
AuthenticationFailed if the header is missing, malformed, or contains
|
|
||||||
an invalid token.
|
|
||||||
"""
|
|
||||||
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
|
||||||
if not required_token:
|
|
||||||
raise AuthenticationFailed("Server-to-server token is not configured.")
|
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
|
||||||
if not auth_header:
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Authorization header is missing.")
|
|
||||||
|
|
||||||
# Validate token format and existence
|
|
||||||
auth_parts = auth_header.split(" ")
|
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: invalid token (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
|
||||||
|
|
||||||
return MachineUser(username="roomkit"), token
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return the WWW-Authenticate header value."""
|
|
||||||
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Serializers for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from core.api.serializers import BaseValidationOnlySerializer
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Validate roomkit join requests from the LiveKit SIP module."""
|
|
||||||
|
|
||||||
pin_code = serializers.CharField(required=True)
|
|
||||||
|
|
||||||
def validate_pin_code(self, value):
|
|
||||||
"""Ensure the PIN code matches the configured length."""
|
|
||||||
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
|
||||||
raise serializers.ValidationError("PIN code length is invalid.")
|
|
||||||
return value
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from rest_framework import decorators, viewsets
|
|
||||||
from rest_framework import (
|
|
||||||
exceptions as drf_exceptions,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
|
||||||
response as drf_response,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
|
||||||
status as drf_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
from core import analytics, models
|
|
||||||
from core.api import permissions, throttling
|
|
||||||
from core.api.feature_flag import FeatureFlag
|
|
||||||
from core.services.sip_management import SIPException, SIPManagement
|
|
||||||
|
|
||||||
from . import authentication, serializers
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitViewSet(viewsets.ViewSet):
|
|
||||||
"""Server-to-server API endpoints for the roomkit integration.
|
|
||||||
|
|
||||||
Groups all interactions between roomkit (SIP) devices and the backend,
|
|
||||||
brokered by the LiveKit SIP module. All endpoints are authenticated
|
|
||||||
with the roomkit server-to-server tokens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [authentication.ServerToServerAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="join",
|
|
||||||
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("roomkit")
|
|
||||||
def join(self, request):
|
|
||||||
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
|
||||||
|
|
||||||
Called by the LiveKit SIP module when a meeting-room device dials in
|
|
||||||
with a PIN code before any WebRTC participant has joined. Resolves the
|
|
||||||
room by PIN and creates its SIP dispatch rule, so the device can enter
|
|
||||||
without waiting for a WebRTC user.
|
|
||||||
|
|
||||||
The webhook-based creation path is kept: both converge on the same rule
|
|
||||||
through the shared SIPManagement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
room = models.Room.objects.get(
|
|
||||||
pin_code=serializer.validated_data["pin_code"]
|
|
||||||
)
|
|
||||||
except models.Room.DoesNotExist as e:
|
|
||||||
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = SIPManagement().ensure_dispatch_rule(room)
|
|
||||||
except SIPException as e:
|
|
||||||
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
|
||||||
|
|
||||||
analytics.capture(
|
|
||||||
request.user,
|
|
||||||
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
|
||||||
{
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": created,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
|
||||||
room.id,
|
|
||||||
created,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"status": "success"},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
@@ -28,7 +28,7 @@ from .room_management import (
|
|||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
from .sip_management import SIPException, SIPManagement
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.sip_management = SIPManagement()
|
self.telephony_service = TelephonyService()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
self._filter_regex = None
|
self._filter_regex = None
|
||||||
@@ -245,12 +245,12 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.ensure_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to create sip dispatch rule for room {room_id}"
|
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room finished event") from e
|
raise ActionFailedError("Failed to process room finished event") from e
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.delete_dispatch_rule(room_id)
|
self.telephony_service.delete_dispatch_rule(room_id)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+10
-38
@@ -1,9 +1,9 @@
|
|||||||
"""SIP management service for managing SIP dispatch rules for room access."""
|
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import TwirpError, TwirpErrorCode
|
from livekit.api import TwirpError
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -17,16 +17,12 @@ from core import utils
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SIPException(Exception):
|
class TelephonyException(Exception):
|
||||||
"""Exception raised when SIP operations fail."""
|
"""Exception raised when telephony operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class DispatchRuleConflictError(SIPException):
|
class TelephonyService:
|
||||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
"""Service for managing participant access through the telephony system (SIP)."""
|
||||||
|
|
||||||
|
|
||||||
class SIPManagement:
|
|
||||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
|
||||||
|
|
||||||
def _rule_name(self, room_id):
|
def _rule_name(self, room_id):
|
||||||
"""Generate the rule name for a room based on its ID."""
|
"""Generate the rule name for a room based on its ID."""
|
||||||
@@ -36,7 +32,7 @@ class SIPManagement:
|
|||||||
async def create_dispatch_rule(self, room):
|
async def create_dispatch_rule(self, room):
|
||||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||||
|
|
||||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
Configures telephony to route incoming SIP calls directly to the specified room
|
||||||
using the room's ID and PIN code for authentication.
|
using the room's ID and PIN code for authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -55,12 +51,10 @@ class SIPManagement:
|
|||||||
try:
|
try:
|
||||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
|
||||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error creating dispatch rule for room %s", room.id
|
"Unexpected error creating dispatch rule for room %s", room.id
|
||||||
)
|
)
|
||||||
raise SIPException("Could not create dispatch rule") from e
|
raise TelephonyException("Could not create dispatch rule") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -85,7 +79,7 @@ class SIPManagement:
|
|||||||
)
|
)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not list dispatch rules") from e
|
raise TelephonyException("Could not list dispatch rules") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@@ -100,28 +94,6 @@ class SIPManagement:
|
|||||||
if existing_rule.name == rule_name
|
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_to_sync
|
||||||
async def delete_dispatch_rule(self, room_id):
|
async def delete_dispatch_rule(self, room_id):
|
||||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||||
@@ -146,7 +118,7 @@ class SIPManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not delete dispatch rules") from e
|
raise TelephonyException("Could not delete dispatch rules") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""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
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the roomkit API of the Meet core app."""
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the roomkit join server-to-server API endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ...factories import RoomFactory
|
|
||||||
from ...services.sip_management import SIPException
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_sip_management():
|
|
||||||
"""Mock the SIPManagement used by the roomkit viewset."""
|
|
||||||
with mock.patch("core.roomkit.viewsets.SIPManagement") as mock_service_class:
|
|
||||||
yield mock_service_class.return_value
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_anonymous(settings, mock_sip_management, client):
|
|
||||||
"""Requests without an Authorization header should be rejected."""
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Authorization header is missing."}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_malformed_authorization_header(settings, mock_sip_management, client):
|
|
||||||
"""Requests with a malformed Authorization header should be rejected."""
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid authorization header."}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_wrong_bearer(settings, mock_sip_management, client):
|
|
||||||
"""Requests with an incorrect bearer token should be rejected."""
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid server-to-server token."}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_token_not_configured(settings, mock_sip_management, client):
|
|
||||||
"""Requests should be rejected when no server-to-server token is configured."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_roomkit_disabled(settings, mock_sip_management, client):
|
|
||||||
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_missing_pin(settings, mock_sip_management, client):
|
|
||||||
"""Requests without a PIN code should be rejected."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field is required."]}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_blank_pin(settings, mock_sip_management, client):
|
|
||||||
"""Requests with a blank PIN code should be rejected."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": ""},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_wrong_pin_length(settings, mock_sip_management, client):
|
|
||||||
"""Requests with a PIN code of unexpected length should be rejected."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "123"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_unknown_pin(settings, mock_sip_management, client):
|
|
||||||
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "0987654321"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "No room found for this PIN code."}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_success(settings, mock_sip_management, client):
|
|
||||||
"""Requests with a valid PIN should create the dispatch rule."""
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_management.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_dispatch_rule_already_exists(settings, mock_sip_management, client):
|
|
||||||
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_management.ensure_dispatch_rule.return_value = False
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_tracks_analytics_event(settings, mock_sip_management, client):
|
|
||||||
"""Successful joins should be tracked with an analytics event."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_management.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
mock_capture.assert_called_once()
|
|
||||||
_user, event, properties = mock_capture.call_args[0]
|
|
||||||
assert str(event) == "roomkit_joined"
|
|
||||||
assert properties == {
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_join_sip_failure(settings, mock_sip_management, client):
|
|
||||||
"""Requests should fail with a server error when the sip management service fails."""
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_management.ensure_dispatch_rule.side_effect = SIPException(
|
|
||||||
"Could not create dispatch rule"
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
raise_request_exception=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
mock_sip_management.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
mock_capture.assert_not_called()
|
|
||||||
@@ -2,9 +2,14 @@
|
|||||||
Test rooms API endpoints in the Meet core app: create.
|
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
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -109,3 +114,38 @@ def test_api_rooms_create_authenticated_existing_slug():
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
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,8 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: list.
|
Test rooms API endpoints in the Meet core app: list.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
|
|||||||
assert len(content["results"]) == 3
|
assert len(content["results"]) == 3
|
||||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||||
assert content["previous"] is None
|
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,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -503,3 +506,40 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
role=str(user_access.role),
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
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,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"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,10 +21,7 @@ from core.services.livekit_events import (
|
|||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
from core.services.room_management import RoomManagementException
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -62,7 +59,7 @@ def test_initialization(
|
|||||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||||
assert isinstance(service.lobby_service, LobbyService)
|
assert isinstance(service.lobby_service, LobbyService)
|
||||||
assert isinstance(service.sip_management, SIPManagement)
|
assert isinstance(service.telephony_service, TelephonyService)
|
||||||
assert isinstance(service.recording_events, RecordingEventsService)
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
@@ -472,11 +469,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -489,31 +486,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_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(
|
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(mock_room_name)
|
mock_data.room.name = str(mock_room_name)
|
||||||
@@ -527,7 +505,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||||
)
|
)
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
@@ -550,9 +528,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
|||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
SIPManagement,
|
TelephonyService,
|
||||||
"delete_dispatch_rule",
|
"delete_dispatch_rule",
|
||||||
side_effect=SIPException("Test error"),
|
side_effect=TelephonyException("Test error"),
|
||||||
)
|
)
|
||||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
@@ -563,7 +541,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
"Failed to delete sip dispatch rule for room "
|
"Failed to delete telephony dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -584,11 +562,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
|||||||
service._handle_room_finished(mock_data)
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
"""Should create telephony dispatch rule when room starts successfully."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -596,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
|
|
||||||
def test_handle_room_started_ignores_existing_dispatch_rule(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should proceed silently when the dispatch rule already exists when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
|
||||||
SIPManagement,
|
|
||||||
"ensure_dispatch_rule",
|
|
||||||
side_effect=SIPException("Test error"),
|
|
||||||
)
|
|
||||||
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
|
|
||||||
|
|
||||||
with pytest.raises(ActionFailedError, match=expected_error):
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(room.id)
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_not_called()
|
mock_create_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||||
|
|||||||
+35
-162
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Test SIP mamagement service.
|
Test telephony service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
|
|||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
DispatchRuleConflictError,
|
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
|
|||||||
|
|
||||||
def test_rule_name():
|
def test_rule_name():
|
||||||
"""Test rule name generation."""
|
"""Test rule name generation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
rule_name = sip_management._rule_name(room.id)
|
rule_name = telephony_service._rule_name(room.id)
|
||||||
|
|
||||||
assert rule_name == f"SIP_{str(room.id)}"
|
assert rule_name == f"SIP_{str(room.id)}"
|
||||||
|
|
||||||
@@ -49,14 +45,14 @@ def test_rule_name():
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_success(mock_client_factory):
|
def test_create_dispatch_rule_success(mock_client_factory):
|
||||||
"""Test successful dispatch rule creation."""
|
"""Test successful dispatch rule creation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||||
@@ -71,7 +67,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||||
"""Test dispatch rule creation when API fails."""
|
"""Test dispatch rule creation when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -90,7 +86,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||||
"""Test successful listing of dispatch rule IDs."""
|
"""Test successful listing of dispatch rule IDs."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert "rule-1" in result
|
assert "rule-1" in result
|
||||||
@@ -131,7 +127,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules exist."""
|
"""Test listing dispatch rule IDs when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -149,7 +145,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -176,7 +172,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when API fails."""
|
"""Test listing dispatch rule IDs when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when no rules exist."""
|
"""Test deleting dispatch rules when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = []
|
mock_list_rules.return_value = []
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_list_rules.assert_called_once_with(room.id)
|
mock_list_rules.assert_called_once_with(room.id)
|
||||||
mock_client_factory.assert_not_called()
|
mock_client_factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting a single dispatch rule."""
|
"""Test deleting a single dispatch rule."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -220,7 +216,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules."""
|
"""Test deleting multiple dispatch rules."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -242,7 +238,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||||
@@ -257,11 +253,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when API fails immediately."""
|
"""Test deleting dispatch rules when API fails immediately."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
|
||||||
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(
|
|
||||||
msg=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(DispatchRuleConflictError):
|
|
||||||
sip_management.create_dispatch_rule(room)
|
|
||||||
|
|
||||||
mock_api.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is True
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
|
||||||
|
|
||||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
|
||||||
assert create_request.name == f"SIP_{str(room.id)}"
|
|
||||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
|
||||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
existing_rule = SIPDispatchRuleInfo(
|
|
||||||
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
|
||||||
)
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
|
||||||
|
|
||||||
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
|
||||||
between the existence check and the creation, LiveKit rejects the
|
|
||||||
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
|
||||||
"""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(
|
|
||||||
msg=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
|
||||||
sip_management.ensure_dispatch_rule(room)
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
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,13 +184,12 @@ def test_models_rooms_is_public_property():
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
def test_telephony_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
):
|
):
|
||||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
"""Telephony disabled should not generate pin codes."""
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
|||||||
assert room.pin_code is None
|
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):
|
def test_default_and_custom_pin_length(settings):
|
||||||
"""Pin codes should be created with correct configured length."""
|
"""Pin codes should be created with correct configured length."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
from core.roomkit import viewsets as roomkit_viewsets
|
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -20,11 +19,6 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
|||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
router.register(
|
|
||||||
"roomkit",
|
|
||||||
roomkit_viewsets.RoomKitViewSet,
|
|
||||||
basename="roomkit",
|
|
||||||
)
|
|
||||||
router.register(
|
router.register(
|
||||||
"addons/sessions",
|
"addons/sessions",
|
||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
@@ -43,6 +37,11 @@ external_router.register(
|
|||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
external_router.register(
|
||||||
|
"users",
|
||||||
|
external_viewsets.UserViewSet,
|
||||||
|
basename="external_user",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||||
"core.authentication.backends.SessionAuthenticationWith401",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
@@ -344,16 +345,16 @@ class Base(Configuration):
|
|||||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
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(
|
"creation_callback": values.Value(
|
||||||
default="600/minute",
|
default="600/minute",
|
||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
"roomkit_join": values.Value(
|
|
||||||
default="300/minute",
|
|
||||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
|
||||||
environ_prefix=None,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -886,21 +887,6 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
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
|
# Subtitles settings
|
||||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||||
@@ -973,6 +959,61 @@ class Base(Configuration):
|
|||||||
environ_name="APPLICATION_BASE_URL",
|
environ_name="APPLICATION_BASE_URL",
|
||||||
environ_prefix=None,
|
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
|
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||||
@@ -1270,6 +1311,10 @@ class Test(Base):
|
|||||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
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
|
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):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
|
|||||||
Reference in New Issue
Block a user